From d542f849aa5ccf0d4183196f1b1977a8ec1fad6a Mon Sep 17 00:00:00 2001 From: knuthtimo-lab Date: Sat, 11 Jul 2026 22:09:10 +0200 Subject: [PATCH] TikTok V5 + Security --- .env.example | 7 +- docker-compose.yml | 5 +- docs/automations/social-accounts-and-jobs.md | 2 + env.example | 3 +- src/app/(main)/api/analytics/summary/route.ts | 4 +- .../(main)/api/auth/forgot-password/route.ts | 21 ++ src/app/(main)/api/auth/google/route.ts | 221 +++++++++--------- .../(main)/api/auth/reset-password/route.ts | 2 +- src/app/(main)/api/auth/signup/route.ts | 103 ++++---- src/app/(main)/api/auth/simple-login/route.ts | 61 ++--- src/app/(main)/api/onboarding/route.ts | 6 +- src/app/(main)/api/qrs/[id]/feedback/route.ts | 7 +- src/app/(main)/api/qrs/[id]/route.ts | 8 +- src/app/(main)/api/qrs/delete-all/route.ts | 4 +- src/app/(main)/api/qrs/route.ts | 40 ++-- src/app/(main)/api/qrs/static/route.ts | 4 +- .../api/stripe/cancel-subscription/route.ts | 90 +++---- .../stripe/create-checkout-session/route.ts | 99 ++++---- src/app/(main)/api/stripe/portal/route.ts | 4 +- .../api/stripe/sync-subscription/route.ts | 48 ++-- .../(main)/api/stripe/verify-session/route.ts | 94 ++++---- src/app/(main)/api/tiktok/callback/route.ts | 13 +- src/app/(main)/api/tiktok/token/route.ts | 26 ++- src/app/(main)/api/upload/route.ts | 3 +- src/app/(main)/api/user/debug/route.ts | 9 +- src/app/(main)/api/user/delete/route.ts | 3 +- src/app/(main)/api/user/password/route.ts | 4 +- src/app/(main)/api/user/plan/route.ts | 4 +- src/app/(main)/api/user/profile/route.ts | 4 +- src/app/(main)/api/user/route.ts | 4 +- src/app/(main)/api/user/stats/route.ts | 4 +- src/lib/csrf.ts | 8 +- src/lib/session-edge.ts | 62 +++++ src/lib/session.ts | 79 +++++++ src/lib/tiktok.ts | 140 ++++++++--- src/middleware.ts | 7 +- 36 files changed, 740 insertions(+), 463 deletions(-) create mode 100644 src/lib/session-edge.ts create mode 100644 src/lib/session.ts diff --git a/.env.example b/.env.example index e4b7aa2..728f3b1 100644 --- a/.env.example +++ b/.env.example @@ -30,7 +30,8 @@ CRON_SECRET= # Production example: https://qrmaster.net/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. -TIKTOK_CLIENT_KEY=aw2l7czcin3uk426 -TIKTOK_CLIENT_SECRET=LapujWkqpZfj1jgWBeCzFhw2nuhMPMBA +TIKTOK_CLIENT_KEY= +TIKTOK_CLIENT_SECRET= TIKTOK_REDIRECT_URI=https://qrmaster.net/api/tiktok/callback -TIKTOK_ADMIN_KEY=yV0CzlXyw00kFc7hnnJZv_eoH5WaqGsD5l8_GH7Aj2s \ No newline at end of file +TIKTOK_ADMIN_KEY= +TIKTOK_EXPECTED_OPEN_ID= diff --git a/docker-compose.yml b/docker-compose.yml index 921d1ec..aeee0c5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,8 +56,9 @@ services: INTERNAL_API_SECRET: ${INTERNAL_API_SECRET} TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-} TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-} - TIKTOK_REDIRECT_URI: ${TIKTOK_REDIRECT_URI:-https://qrmaster.net/api/tiktok/callback} - TIKTOK_ADMIN_KEY: ${TIKTOK_ADMIN_KEY:-} + TIKTOK_REDIRECT_URI: ${TIKTOK_REDIRECT_URI:-https://qrmaster.net/api/tiktok/callback} + TIKTOK_ADMIN_KEY: ${TIKTOK_ADMIN_KEY:-} + TIKTOK_EXPECTED_OPEN_ID: ${TIKTOK_EXPECTED_OPEN_ID:-} IP_SALT: ${IP_SALT:-your-salt-change-in-production} ENABLE_DEMO: ${ENABLE_DEMO:-false} NEXT_PUBLIC_INDEXABLE: ${NEXT_PUBLIC_INDEXABLE:-true} diff --git a/docs/automations/social-accounts-and-jobs.md b/docs/automations/social-accounts-and-jobs.md index f2b0506..22736dc 100644 --- a/docs/automations/social-accounts-and-jobs.md +++ b/docs/automations/social-accounts-and-jobs.md @@ -39,6 +39,8 @@ - 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`. - 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. ## TikTok Connection Status + Credential Locations diff --git a/env.example b/env.example index 6c32b24..e321c44 100644 --- a/env.example +++ b/env.example @@ -54,4 +54,5 @@ TIKTOK_CLIENT_KEY= TIKTOK_CLIENT_SECRET= TIKTOK_REDIRECT_URI=https://qrmaster.net/api/tiktok/callback # Optional: protects /api/tiktok/connect from being triggered by strangers -TIKTOK_ADMIN_KEY= +TIKTOK_ADMIN_KEY= +TIKTOK_EXPECTED_OPEN_ID= diff --git a/src/app/(main)/api/analytics/summary/route.ts b/src/app/(main)/api/analytics/summary/route.ts index 0b60aa9..1202e71 100644 --- a/src/app/(main)/api/analytics/summary/route.ts +++ b/src/app/(main)/api/analytics/summary/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; +import { getSessionUserId } from '@/lib/session'; import { db } from '@/lib/db'; import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; import { TrendData } from '@/types/analytics'; @@ -41,7 +41,7 @@ function calculateTrend(current: number, previous: number): TrendData { export async function GET(request: NextRequest) { try { - const userId = cookies().get('userId')?.value; + const userId = getSessionUserId(); // Rate Limiting (user-based) const clientId = userId || getClientIdentifier(request); diff --git a/src/app/(main)/api/auth/forgot-password/route.ts b/src/app/(main)/api/auth/forgot-password/route.ts index ea7e87e..9cecc08 100644 --- a/src/app/(main)/api/auth/forgot-password/route.ts +++ b/src/app/(main)/api/auth/forgot-password/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { csrfProtection } from '@/lib/csrf'; +import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; import { sendPasswordResetEmail } from '@/lib/email'; 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 { email } = body; diff --git a/src/app/(main)/api/auth/google/route.ts b/src/app/(main)/api/auth/google/route.ts index 7dadb68..ca5189b 100644 --- a/src/app/(main)/api/auth/google/route.ts +++ b/src/app/(main)/api/auth/google/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getAuthCookieOptions } from '@/lib/cookieConfig'; +import { signUserId } from '@/lib/session'; import { appendRedirectParam, GOOGLE_OAUTH_STATE_COOKIE_NAME, @@ -27,14 +28,14 @@ export async function GET(request: NextRequest) { // If no code, redirect to Google OAuth if (!code) { - const googleClientId = process.env.GOOGLE_CLIENT_ID; - - if (!googleClientId) { - return NextResponse.json( - { error: 'Google Client ID not configured' }, - { status: 500 } - ); - } + const googleClientId = process.env.GOOGLE_CLIENT_ID; + + if (!googleClientId) { + return NextResponse.json( + { error: 'Google Client ID not configured' }, + { status: 500 } + ); + } const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`; const scope = 'openid email profile'; @@ -85,58 +86,58 @@ export async function GET(request: NextRequest) { const googleClientId = process.env.GOOGLE_CLIENT_ID; const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET; - - if (!googleClientId || !googleClientSecret) { - return NextResponse.json( - { error: 'Google OAuth not configured' }, - { status: 500 } - ); - } - - const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`; - - // Exchange code for tokens - const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - code, - client_id: googleClientId, - client_secret: googleClientSecret, + + if (!googleClientId || !googleClientSecret) { + return NextResponse.json( + { error: 'Google OAuth not configured' }, + { status: 500 } + ); + } + + const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`; + + // Exchange code for tokens + const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + code, + client_id: googleClientId, + client_secret: googleClientSecret, redirect_uri: redirectUri, grant_type: 'authorization_code', }), - }); - - if (!tokenResponse.ok) { - throw new Error('Failed to exchange code for tokens'); - } - - const tokens = await tokenResponse.json(); - - // Get user info from Google - const userInfoResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { - headers: { - Authorization: `Bearer ${tokens.access_token}`, - }, - }); - - if (!userInfoResponse.ok) { - throw new Error('Failed to get user info'); - } - - const userInfo = await userInfoResponse.json(); - - // Check if user exists in database - let user = await db.user.findUnique({ - where: { email: userInfo.email }, - }); - - const isNewUser = !user; - - // Create user if they don't exist + }); + + if (!tokenResponse.ok) { + throw new Error('Failed to exchange code for tokens'); + } + + const tokens = await tokenResponse.json(); + + // Get user info from Google + const userInfoResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + }, + }); + + if (!userInfoResponse.ok) { + throw new Error('Failed to get user info'); + } + + const userInfo = await userInfoResponse.json(); + + // Check if user exists in database + let user = await db.user.findUnique({ + where: { email: userInfo.email }, + }); + + const isNewUser = !user; + + // Create user if they don't exist if (!user) { const onboardingStartedAt = new Date(); user = await db.user.create({ @@ -158,58 +159,58 @@ export async function GET(request: NextRequest) { signupFirstSeenAt: firstTouch?.signupFirstSeenAt ? new Date(firstTouch.signupFirstSeenAt) : onboardingStartedAt, }, }); - - // Create Account entry for the OAuth provider - await db.account.create({ - data: { - userId: user.id, - type: 'oauth', - provider: 'google', - providerAccountId: userInfo.sub || userInfo.id, - access_token: tokens.access_token, - refresh_token: tokens.refresh_token, - expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null, - token_type: tokens.token_type, - scope: tokens.scope, - id_token: tokens.id_token, - }, - }); - } else { - // Update existing account tokens - const existingAccount = await db.account.findUnique({ - where: { - provider_providerAccountId: { - provider: 'google', - providerAccountId: userInfo.sub || userInfo.id, - }, - }, - }); - - if (existingAccount) { - await db.account.update({ - where: { id: existingAccount.id }, - data: { - access_token: tokens.access_token, - refresh_token: tokens.refresh_token, - expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null, - }, - }); - } else { - // Create Account entry if it doesn't exist - await db.account.create({ - data: { - userId: user.id, - type: 'oauth', - provider: 'google', - providerAccountId: userInfo.sub || userInfo.id, - access_token: tokens.access_token, - refresh_token: tokens.refresh_token, - expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null, - token_type: tokens.token_type, - scope: tokens.scope, - id_token: tokens.id_token, - }, - }); + + // Create Account entry for the OAuth provider + await db.account.create({ + data: { + userId: user.id, + type: 'oauth', + provider: 'google', + providerAccountId: userInfo.sub || userInfo.id, + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null, + token_type: tokens.token_type, + scope: tokens.scope, + id_token: tokens.id_token, + }, + }); + } else { + // Update existing account tokens + const existingAccount = await db.account.findUnique({ + where: { + provider_providerAccountId: { + provider: 'google', + providerAccountId: userInfo.sub || userInfo.id, + }, + }, + }); + + if (existingAccount) { + await db.account.update({ + where: { id: existingAccount.id }, + data: { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null, + }, + }); + } else { + // Create Account entry if it doesn't exist + await db.account.create({ + data: { + userId: user.id, + type: 'oauth', + provider: 'google', + providerAccountId: userInfo.sub || userInfo.id, + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null, + token_type: tokens.token_type, + scope: tokens.scope, + id_token: tokens.id_token, + }, + }); } } @@ -227,7 +228,7 @@ export async function GET(request: NextRequest) { const redirectUrl = new URL(`${process.env.NEXT_PUBLIC_APP_URL}${onboardingTarget}`); 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(POST_AUTH_REDIRECT_COOKIE_NAME); response.cookies.delete(ATTRIBUTION_COOKIE_NAME); diff --git a/src/app/(main)/api/auth/reset-password/route.ts b/src/app/(main)/api/auth/reset-password/route.ts index 9dd7a28..7622613 100644 --- a/src/app/(main)/api/auth/reset-password/route.ts +++ b/src/app/(main)/api/auth/reset-password/route.ts @@ -62,7 +62,7 @@ export async function POST(req: NextRequest) { } // 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 await db.user.update({ diff --git a/src/app/(main)/api/auth/signup/route.ts b/src/app/(main)/api/auth/signup/route.ts index ef5ebb6..a438152 100644 --- a/src/app/(main)/api/auth/signup/route.ts +++ b/src/app/(main)/api/auth/signup/route.ts @@ -1,19 +1,20 @@ -import { NextRequest, NextResponse } from 'next/server'; -import bcrypt from 'bcryptjs'; -import { db } from '@/lib/db'; +import { NextRequest, NextResponse } from 'next/server'; +import bcrypt from 'bcryptjs'; +import { db } from '@/lib/db'; import { z } from 'zod'; import { csrfProtection } from '@/lib/csrf'; import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; -import { getAuthCookieOptions } from '@/lib/cookieConfig'; -import { signupSchema, validateRequest } from '@/lib/validationSchemas'; -import { sendWelcomeEmail } from '@/lib/email'; -import { sendConversionEvent } from '@/lib/metaConversions'; -import { - ATTRIBUTION_COOKIE_NAME, - getEmailDomain, - parseAttributionCookie, -} from '@/lib/revops'; -import { triggerLifecycleScoring } from '@/lib/revops-server'; +import { getAuthCookieOptions } from '@/lib/cookieConfig'; +import { signUserId } from '@/lib/session'; +import { signupSchema, validateRequest } from '@/lib/validationSchemas'; +import { sendWelcomeEmail } from '@/lib/email'; +import { sendConversionEvent } from '@/lib/metaConversions'; +import { + ATTRIBUTION_COOKIE_NAME, + getEmailDomain, + parseAttributionCookie, +} from '@/lib/revops'; +import { triggerLifecycleScoring } from '@/lib/revops-server'; export async function POST(request: NextRequest) { try { @@ -72,29 +73,29 @@ export async function POST(request: NextRequest) { // Hash password const hashedPassword = await bcrypt.hash(password, 12); - const firstTouch = parseAttributionCookie(request.cookies.get(ATTRIBUTION_COOKIE_NAME)?.value); - const onboardingStartedAt = new Date(); - - // Create user - const user = await db.user.create({ - data: { - name, - email, - password: hashedPassword, - onboardingStartedAt, - emailDomain: getEmailDomain(email), - signupSource: firstTouch?.signupSource || null, - signupMedium: firstTouch?.signupMedium || null, - signupCampaign: firstTouch?.signupCampaign || null, - signupContent: firstTouch?.signupContent || null, - signupTerm: firstTouch?.signupTerm || null, - signupReferrer: firstTouch?.signupReferrer || null, - signupLandingPath: firstTouch?.signupLandingPath || '/signup', - signupFirstSeenAt: firstTouch?.signupFirstSeenAt ? new Date(firstTouch.signupFirstSeenAt) : onboardingStartedAt, - }, - }); - - triggerLifecycleScoring(user.id, 'signup'); + const firstTouch = parseAttributionCookie(request.cookies.get(ATTRIBUTION_COOKIE_NAME)?.value); + const onboardingStartedAt = new Date(); + + // Create user + const user = await db.user.create({ + data: { + name, + email, + password: hashedPassword, + onboardingStartedAt, + emailDomain: getEmailDomain(email), + signupSource: firstTouch?.signupSource || null, + signupMedium: firstTouch?.signupMedium || null, + signupCampaign: firstTouch?.signupCampaign || null, + signupContent: firstTouch?.signupContent || null, + signupTerm: firstTouch?.signupTerm || null, + signupReferrer: firstTouch?.signupReferrer || null, + signupLandingPath: firstTouch?.signupLandingPath || '/signup', + signupFirstSeenAt: firstTouch?.signupFirstSeenAt ? new Date(firstTouch.signupFirstSeenAt) : onboardingStartedAt, + }, + }); + + triggerLifecycleScoring(user.id, 'signup'); // Send welcome email (fire-and-forget — never block signup) try { @@ -117,22 +118,22 @@ export async function POST(request: NextRequest) { }).catch(console.error); // Create response - const response = NextResponse.json({ - success: true, - needsOnboarding: true, - user: { - id: user.id, - name: user.name, - email: user.email, - plan: 'FREE', + const response = NextResponse.json({ + success: true, + needsOnboarding: true, + user: { + id: user.id, + name: user.name, + email: user.email, + plan: 'FREE', }, }); - - // Set cookie for auto-login after signup - response.cookies.set('userId', user.id, getAuthCookieOptions()); - response.cookies.delete(ATTRIBUTION_COOKIE_NAME); - - return response; + + // Set cookie for auto-login after signup + response.cookies.set('userId', signUserId(user.id), getAuthCookieOptions()); + response.cookies.delete(ATTRIBUTION_COOKIE_NAME); + + return response; } catch (error) { if (error instanceof z.ZodError) { return NextResponse.json( @@ -147,4 +148,4 @@ export async function POST(request: NextRequest) { { status: 500 } ); } -} +} diff --git a/src/app/(main)/api/auth/simple-login/route.ts b/src/app/(main)/api/auth/simple-login/route.ts index 650689c..1100042 100644 --- a/src/app/(main)/api/auth/simple-login/route.ts +++ b/src/app/(main)/api/auth/simple-login/route.ts @@ -1,12 +1,15 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import bcrypt from 'bcryptjs'; -import { cookies } from 'next/headers'; -import { csrfProtection } from '@/lib/csrf'; -import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; -import { getAuthCookieOptions } from '@/lib/cookieConfig'; -import { loginSchema, validateRequest } from '@/lib/validationSchemas'; -import { shouldResumeOnboarding } from '@/lib/revops'; +import { csrfProtection } from '@/lib/csrf'; +import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; +import { setSessionCookie } from '@/lib/session'; +import { loginSchema, validateRequest } from '@/lib/validationSchemas'; +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) { try { @@ -51,28 +54,30 @@ export async function POST(request: NextRequest) { const { email, password } = validation.data; // Find user - const user = await db.user.findUnique({ - where: { email }, - select: { - id: true, - email: true, - name: true, - plan: true, - password: true, - onboardingStartedAt: true, - onboardingCompletedAt: true, - }, - }); + const user = await db.user.findUnique({ + where: { email }, + select: { + id: true, + email: true, + name: true, + plan: true, + password: true, + onboardingStartedAt: true, + onboardingCompletedAt: true, + }, + }); if (!user) { + // Equalize response time to prevent user enumeration via timing. + await bcrypt.compare(password, DUMMY_BCRYPT_HASH); return NextResponse.json( { error: 'Invalid email or password' }, { status: 401 } ); } - // Verify password - const isValid = await bcrypt.compare(password, user.password || ''); + // Verify password (dummy-compare when no hash exists, e.g. OAuth-only accounts) + const isValid = await bcrypt.compare(password, user.password || DUMMY_BCRYPT_HASH); if (!isValid) { return NextResponse.json( @@ -81,16 +86,16 @@ export async function POST(request: NextRequest) { ); } - // Set cookie - cookies().set('userId', user.id, getAuthCookieOptions()); + // Set signed session cookie + setSessionCookie(user.id); - return NextResponse.json({ - success: true, - needsOnboarding: shouldResumeOnboarding(user), - user: { id: user.id, email: user.email, name: user.name, plan: user.plan || 'FREE' } - }); + return NextResponse.json({ + success: true, + needsOnboarding: shouldResumeOnboarding(user), + user: { id: user.id, email: user.email, name: user.name, plan: user.plan || 'FREE' } + }); } catch (error) { console.error('Login error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } -} +} diff --git a/src/app/(main)/api/onboarding/route.ts b/src/app/(main)/api/onboarding/route.ts index fd89cbb..64e7d12 100644 --- a/src/app/(main)/api/onboarding/route.ts +++ b/src/app/(main)/api/onboarding/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; +import { getSessionUserId } from '@/lib/session'; import { db } from '@/lib/db'; import { csrfProtection } from '@/lib/csrf'; import { getClientIdentifier, rateLimit, RateLimits } from '@/lib/rateLimit'; @@ -10,7 +10,7 @@ export const dynamic = 'force-dynamic'; export async function GET() { try { - const userId = cookies().get('userId')?.value; + const userId = getSessionUserId(); if (!userId) { 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 }); } - const userId = cookies().get('userId')?.value; + const userId = getSessionUserId(); const clientId = userId || getClientIdentifier(request); const rateLimitResult = rateLimit(clientId, RateLimits.PROFILE_UPDATE); diff --git a/src/app/(main)/api/qrs/[id]/feedback/route.ts b/src/app/(main)/api/qrs/[id]/feedback/route.ts index 2807e55..ec03208 100644 --- a/src/app/(main)/api/qrs/[id]/feedback/route.ts +++ b/src/app/(main)/api/qrs/[id]/feedback/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { db } from '@/lib/db'; -import { cookies } from 'next/headers'; +import { getSessionUserId } from '@/lib/session'; export async function GET( request: NextRequest, @@ -16,9 +16,8 @@ export async function GET( if (session?.user?.id) { userId = session.user.id; } else { - // Fallback: Check raw userId cookie (like /api/user does) - const cookieStore = await cookies(); - userId = cookieStore.get('userId')?.value; + // Fallback: verified signed userId cookie (like /api/user does) + userId = getSessionUserId() ?? undefined; } if (!userId) { diff --git a/src/app/(main)/api/qrs/[id]/route.ts b/src/app/(main)/api/qrs/[id]/route.ts index 6f451c7..985cbc2 100644 --- a/src/app/(main)/api/qrs/[id]/route.ts +++ b/src/app/(main)/api/qrs/[id]/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; +import { getSessionUserId } from '@/lib/session'; import { db } from '@/lib/db'; import { z } from 'zod'; import { csrfProtection } from '@/lib/csrf'; @@ -18,7 +18,7 @@ export async function GET( { params }: { params: { id: string } } ) { try { - const userId = cookies().get('userId')?.value; + const userId = getSessionUserId(); if (!userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } @@ -62,7 +62,7 @@ export async function PATCH( return NextResponse.json({ error: csrfCheck.error }, { status: 403 }); } - const userId = cookies().get('userId')?.value; + const userId = getSessionUserId(); // Rate Limiting (user-based) const clientId = userId || getClientIdentifier(request); @@ -151,7 +151,7 @@ export async function DELETE( return NextResponse.json({ error: csrfCheck.error }, { status: 403 }); } - const userId = cookies().get('userId')?.value; + const userId = getSessionUserId(); // Rate Limiting (user-based) const clientId = userId || getClientIdentifier(request); diff --git a/src/app/(main)/api/qrs/delete-all/route.ts b/src/app/(main)/api/qrs/delete-all/route.ts index ced2dde..9d88371 100644 --- a/src/app/(main)/api/qrs/delete-all/route.ts +++ b/src/app/(main)/api/qrs/delete-all/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; +import { getSessionUserId } from '@/lib/session'; import { db } from '@/lib/db'; import { csrfProtection } from '@/lib/csrf'; 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) const clientId = userId || getClientIdentifier(request); diff --git a/src/app/(main)/api/qrs/route.ts b/src/app/(main)/api/qrs/route.ts index 1103e6c..86b5e24 100644 --- a/src/app/(main)/api/qrs/route.ts +++ b/src/app/(main)/api/qrs/route.ts @@ -1,17 +1,17 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; +import { getSessionUserId } from '@/lib/session'; import { db } from '@/lib/db'; -import { generateSlug } from '@/lib/hash'; -import { createQRSchema, validateRequest } from '@/lib/validationSchemas'; -import { csrfProtection } from '@/lib/csrf'; -import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; -import { DYNAMIC_QR_LIMITS } from '@/lib/plans'; -import { triggerLifecycleScoring } from '@/lib/revops-server'; +import { generateSlug } from '@/lib/hash'; +import { createQRSchema, validateRequest } from '@/lib/validationSchemas'; +import { csrfProtection } from '@/lib/csrf'; +import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; +import { DYNAMIC_QR_LIMITS } from '@/lib/plans'; +import { triggerLifecycleScoring } from '@/lib/revops-server'; // GET /api/qrs - List user's QR codes export async function GET(request: NextRequest) { try { - const userId = cookies().get('userId')?.value; + const userId = getSessionUserId(); if (!userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } @@ -49,7 +49,7 @@ export async function GET(request: NextRequest) { } // Plan limits -const PLAN_LIMITS = DYNAMIC_QR_LIMITS; +const PLAN_LIMITS = DYNAMIC_QR_LIMITS; // POST /api/qrs - Create a new QR code export async function POST(request: NextRequest) { @@ -60,7 +60,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: csrfCheck.error }, { status: 403 }); } - const userId = cookies().get('userId')?.value; + const userId = getSessionUserId(); // Rate Limiting (user-based) const clientId = userId || getClientIdentifier(request); @@ -205,9 +205,9 @@ END:VCARD`; const slug = generateSlug(body.title); // Create QR code - const qrCode = await db.qRCode.create({ - data: { - userId, + const qrCode = await db.qRCode.create({ + data: { + userId, title: body.title, type: isStatic ? 'STATIC' : 'DYNAMIC', contentType: body.contentType, @@ -221,12 +221,12 @@ END:VCARD`; }, slug, status: 'ACTIVE', - }, - }); - - triggerLifecycleScoring(userId, 'qr_created'); - - return NextResponse.json(qrCode); + }, + }); + + triggerLifecycleScoring(userId, 'qr_created'); + + return NextResponse.json(qrCode); } catch (error) { console.error('Error creating QR code:', error); return NextResponse.json( @@ -234,4 +234,4 @@ END:VCARD`; { status: 500 } ); } -} +} diff --git a/src/app/(main)/api/qrs/static/route.ts b/src/app/(main)/api/qrs/static/route.ts index 65cf04b..aaf4493 100644 --- a/src/app/(main)/api/qrs/static/route.ts +++ b/src/app/(main)/api/qrs/static/route.ts @@ -1,12 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; +import { getSessionUserId } from '@/lib/session'; import { db } from '@/lib/db'; import { generateSlug } from '@/lib/hash'; // POST /api/qrs/static - Create a STATIC QR code that contains the direct URL export async function POST(request: NextRequest) { try { - const userId = cookies().get('userId')?.value; + const userId = getSessionUserId(); if (!userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/src/app/(main)/api/stripe/cancel-subscription/route.ts b/src/app/(main)/api/stripe/cancel-subscription/route.ts index ca3f91c..c6e871c 100644 --- a/src/app/(main)/api/stripe/cancel-subscription/route.ts +++ b/src/app/(main)/api/stripe/cancel-subscription/route.ts @@ -1,13 +1,13 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; -import { stripe } from '@/lib/stripe'; -import { db } from '@/lib/db'; -import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; -import { scoreUserLifecycle } from '@/lib/revops-server'; +import { getSessionUserId } from '@/lib/session'; +import { stripe } from '@/lib/stripe'; +import { db } from '@/lib/db'; +import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; +import { scoreUserLifecycle } from '@/lib/revops-server'; export async function POST(request: NextRequest) { try { - const userId = cookies().get('userId')?.value; + const userId = getSessionUserId(); // Rate Limiting (user-based) const clientId = userId || getClientIdentifier(request); @@ -34,14 +34,14 @@ export async function POST(request: NextRequest) { } // Get user with subscription info - const user = await db.user.findUnique({ - where: { id: userId }, - select: { - stripeSubscriptionId: true, - stripeCurrentPeriodEnd: true, - plan: true, - }, - }); + const user = await db.user.findUnique({ + where: { id: userId }, + select: { + stripeSubscriptionId: true, + stripeCurrentPeriodEnd: true, + plan: true, + }, + }); if (!user) { return NextResponse.json({ error: 'User not found' }, { status: 404 }); @@ -55,42 +55,42 @@ export async function POST(request: NextRequest) { // No active subscription if (!user.stripeSubscriptionId) { // Just update plan to FREE if somehow plan is not FREE but no subscription - await db.user.update({ - where: { id: userId }, - data: { + await db.user.update({ + where: { id: userId }, + data: { plan: 'FREE', stripePriceId: null, stripeCurrentPeriodEnd: null, - }, - }); - await scoreUserLifecycle(userId, 'subscription_deleted'); - return NextResponse.json({ success: true }); + }, + }); + await scoreUserLifecycle(userId, 'subscription_deleted'); + return NextResponse.json({ success: true }); } - // Schedule cancellation at the end of the paid period so paid features stay active. - const subscription: any = await stripe.subscriptions.update(user.stripeSubscriptionId, { - cancel_at_period_end: true, - }); - - const periodEndTimestamp = subscription.current_period_end - || subscription.currentPeriodEnd - || subscription.billing_cycle_anchor; - - const currentPeriodEnd = periodEndTimestamp - ? new Date(periodEndTimestamp * 1000) - : user.stripeCurrentPeriodEnd; - - // Keep the paid plan locally until Stripe sends customer.subscription.deleted. - await db.user.update({ - where: { id: userId }, - data: { - stripeCurrentPeriodEnd: currentPeriodEnd, - }, - }); - - await scoreUserLifecycle(userId, 'subscription_canceled_at_period_end'); - - return NextResponse.json({ success: true, currentPeriodEnd }); + // Schedule cancellation at the end of the paid period so paid features stay active. + const subscription: any = await stripe.subscriptions.update(user.stripeSubscriptionId, { + cancel_at_period_end: true, + }); + + const periodEndTimestamp = subscription.current_period_end + || subscription.currentPeriodEnd + || subscription.billing_cycle_anchor; + + const currentPeriodEnd = periodEndTimestamp + ? new Date(periodEndTimestamp * 1000) + : user.stripeCurrentPeriodEnd; + + // Keep the paid plan locally until Stripe sends customer.subscription.deleted. + await db.user.update({ + where: { id: userId }, + data: { + stripeCurrentPeriodEnd: currentPeriodEnd, + }, + }); + + await scoreUserLifecycle(userId, 'subscription_canceled_at_period_end'); + + return NextResponse.json({ success: true, currentPeriodEnd }); } catch (error) { console.error('Error canceling subscription:', error); return NextResponse.json( diff --git a/src/app/(main)/api/stripe/create-checkout-session/route.ts b/src/app/(main)/api/stripe/create-checkout-session/route.ts index 6f011f0..be79a83 100644 --- a/src/app/(main)/api/stripe/create-checkout-session/route.ts +++ b/src/app/(main)/api/stripe/create-checkout-session/route.ts @@ -1,14 +1,13 @@ import { NextRequest, NextResponse } from 'next/server'; import { stripe, STRIPE_PLANS } from '@/lib/stripe'; import { db } from '@/lib/db'; -import { cookies } from 'next/headers'; +import { getSessionUserId } from '@/lib/session'; import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; export async function POST(request: NextRequest) { try { - // Get user from cookie (using userId like other routes) - const cookieStore = await cookies(); - const userId = cookieStore.get('userId')?.value; + // Get user from verified signed session cookie + const userId = getSessionUserId(); // Rate Limiting (user-based) const clientId = userId || getClientIdentifier(request); @@ -65,29 +64,29 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'User not found' }, { status: 404 }); } - // Create or get Stripe customer - let customerId = user.stripeCustomerId; - - if (customerId) { - try { - const existingCustomer = await stripe.customers.retrieve(customerId); - - if ('deleted' in existingCustomer && existingCustomer.deleted) { - customerId = null; - } - } catch (error: any) { - if (error?.code === 'resource_missing' || error?.type === 'StripeInvalidRequestError') { - customerId = null; - } else { - throw error; - } - } - } - - if (!customerId) { - const customer = await stripe.customers.create({ - email: user.email, - metadata: { + // Create or get Stripe customer + let customerId = user.stripeCustomerId; + + if (customerId) { + try { + const existingCustomer = await stripe.customers.retrieve(customerId); + + if ('deleted' in existingCustomer && existingCustomer.deleted) { + customerId = null; + } + } catch (error: any) { + if (error?.code === 'resource_missing' || error?.type === 'StripeInvalidRequestError') { + customerId = null; + } else { + throw error; + } + } + } + + if (!customerId) { + const customer = await stripe.customers.create({ + email: user.email, + metadata: { userId: user.id, }, }); @@ -95,34 +94,34 @@ export async function POST(request: NextRequest) { customerId = customer.id; // Update user with Stripe customer ID - await db.user.update({ - where: { id: user.id }, - data: { stripeCustomerId: customerId }, - }); - } - - const appUrl = process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin; - - // Create Stripe Checkout Session - const checkoutSession = await stripe.checkout.sessions.create({ - customer: customerId, - mode: 'subscription', + await db.user.update({ + where: { id: user.id }, + data: { stripeCustomerId: customerId }, + }); + } + + const appUrl = process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin; + + // Create Stripe Checkout Session + const checkoutSession = await stripe.checkout.sessions.create({ + customer: customerId, + mode: 'subscription', payment_method_types: ['card'], - allow_promotion_codes: true, + allow_promotion_codes: true, line_items: [ { price: priceId, quantity: 1, - }, - ], - success_url: `${appUrl}/dashboard?success=true&session_id={CHECKOUT_SESSION_ID}`, - cancel_url: `${appUrl}/pricing?canceled=true`, - metadata: { - userId: user.id, - plan, - billingInterval, - }, - }); + }, + ], + success_url: `${appUrl}/dashboard?success=true&session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${appUrl}/pricing?canceled=true`, + metadata: { + userId: user.id, + plan, + billingInterval, + }, + }); return NextResponse.json({ url: checkoutSession.url }); } catch (error) { diff --git a/src/app/(main)/api/stripe/portal/route.ts b/src/app/(main)/api/stripe/portal/route.ts index 52c4580..bbe0d58 100644 --- a/src/app/(main)/api/stripe/portal/route.ts +++ b/src/app/(main)/api/stripe/portal/route.ts @@ -1,12 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; +import { getSessionUserId } from '@/lib/session'; import { stripe } from '@/lib/stripe'; import { db } from '@/lib/db'; import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; export async function POST(request: NextRequest) { try { - const userId = cookies().get('userId')?.value; + const userId = getSessionUserId(); // Rate Limiting (user-based) const clientId = userId || getClientIdentifier(request); diff --git a/src/app/(main)/api/stripe/sync-subscription/route.ts b/src/app/(main)/api/stripe/sync-subscription/route.ts index c78a9d7..abee334 100644 --- a/src/app/(main)/api/stripe/sync-subscription/route.ts +++ b/src/app/(main)/api/stripe/sync-subscription/route.ts @@ -1,8 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; -import { stripe } from '@/lib/stripe'; -import { db } from '@/lib/db'; -import { scoreUserLifecycle } from '@/lib/revops-server'; +import { getSessionUserId } from '@/lib/session'; +import { stripe } from '@/lib/stripe'; +import { db } from '@/lib/db'; +import { scoreUserLifecycle } from '@/lib/revops-server'; /** * Manual sync endpoint to update user subscription from Stripe @@ -11,7 +11,7 @@ import { scoreUserLifecycle } from '@/lib/revops-server'; export async function POST(request: NextRequest) { try { // Use cookie-based auth - const userId = cookies().get('userId')?.value; + const userId = getSessionUserId(); if (!userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); @@ -38,19 +38,19 @@ export async function POST(request: NextRequest) { if (subscriptions.data.length === 0) { // No active subscription - set to FREE - await db.user.update({ - where: { id: user.id }, - data: { + await db.user.update({ + where: { id: user.id }, + data: { stripeSubscriptionId: null, stripePriceId: null, stripeCurrentPeriodEnd: null, plan: 'FREE', - }, - }); - - await scoreUserLifecycle(user.id, 'subscription_deleted'); - - return NextResponse.json({ + }, + }); + + await scoreUserLifecycle(user.id, 'subscription_deleted'); + + return NextResponse.json({ success: true, plan: 'FREE', message: 'No active subscription found, set to FREE plan', @@ -90,20 +90,20 @@ export async function POST(request: NextRequest) { }); // Update user in database - await db.user.update({ - where: { id: user.id }, - data: { + await db.user.update({ + where: { id: user.id }, + data: { stripeSubscriptionId: subscription.id, stripePriceId: priceId, stripeCurrentPeriodEnd: currentPeriodEnd, plan: plan as any, - }, - }); - - await scoreUserLifecycle(user.id, 'subscription_synced'); - - return NextResponse.json({ - success: true, + }, + }); + + await scoreUserLifecycle(user.id, 'subscription_synced'); + + return NextResponse.json({ + success: true, plan, subscriptionId: subscription.id, currentPeriodEnd, diff --git a/src/app/(main)/api/stripe/verify-session/route.ts b/src/app/(main)/api/stripe/verify-session/route.ts index a11deba..7eb376d 100644 --- a/src/app/(main)/api/stripe/verify-session/route.ts +++ b/src/app/(main)/api/stripe/verify-session/route.ts @@ -1,26 +1,26 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; +import { getSessionUserId } from '@/lib/session'; import { stripe } from '@/lib/stripe'; import { db } from '@/lib/db'; import { scoreUserLifecycle } from '@/lib/revops-server'; - -export async function POST(request: NextRequest) { - try { - // Use cookie-based auth instead of NextAuth - const userId = cookies().get('userId')?.value; - - if (!userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const user = await db.user.findUnique({ - where: { id: userId }, - }); - - if (!user) { - return NextResponse.json({ error: 'User not found' }, { status: 404 }); - } - + +export async function POST(request: NextRequest) { + try { + // Use cookie-based auth instead of NextAuth + const userId = getSessionUserId(); + + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const user = await db.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + if (!user.stripeCustomerId) { return NextResponse.json({ error: 'No Stripe customer ID' }, { status: 400 }); } @@ -45,31 +45,31 @@ export async function POST(request: NextRequest) { if (checkoutSession.payment_status === 'paid' && checkoutSession.subscription) { const subscriptionId = typeof checkoutSession.subscription === 'string' ? checkoutSession.subscription - : checkoutSession.subscription.id; - - // Retrieve the full subscription object - const subscription: any = await stripe.subscriptions.retrieve(subscriptionId); - - // Determine plan from metadata or price ID - const plan = checkoutSession.metadata?.plan || 'PRO'; - + : checkoutSession.subscription.id; + + // Retrieve the full subscription object + const subscription: any = await stripe.subscriptions.retrieve(subscriptionId); + + // Determine plan from metadata or price ID + const plan = checkoutSession.metadata?.plan || 'PRO'; + // Get current_period_end - Stripe returns it as a Unix timestamp const periodEndTimestamp = subscription.current_period_end || subscription.currentPeriodEnd || subscription.billing_cycle_anchor; - - const currentPeriodEnd = periodEndTimestamp - ? new Date(periodEndTimestamp * 1000) - : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // Default to 30 days from now - + + const currentPeriodEnd = periodEndTimestamp + ? new Date(periodEndTimestamp * 1000) + : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // Default to 30 days from now + // Update user in database await db.user.update({ where: { id: user.id }, data: { stripeSubscriptionId: subscription.id, stripePriceId: subscription.items.data[0].price.id, - stripeCurrentPeriodEnd: currentPeriodEnd, - plan: plan as any, + stripeCurrentPeriodEnd: currentPeriodEnd, + plan: plan as any, }, }); @@ -79,15 +79,15 @@ export async function POST(request: NextRequest) { success: true, plan, subscriptionId: subscription.id, - }); - } - - return NextResponse.json({ error: 'Payment not completed' }, { status: 400 }); - } catch (error) { - console.error('Error verifying session:', error); - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ); - } -} + }); + } + + return NextResponse.json({ error: 'Payment not completed' }, { status: 400 }); + } catch (error) { + console.error('Error verifying session:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/(main)/api/tiktok/callback/route.ts b/src/app/(main)/api/tiktok/callback/route.ts index 94288c0..03efe68 100644 --- a/src/app/(main)/api/tiktok/callback/route.ts +++ b/src/app/(main)/api/tiktok/callback/route.ts @@ -1,6 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; 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 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'); } + // Reject the wrong browser account before it can replace the valid QRMaster connection. + assertExpectedTiktokAccount(tokens.open_id); + const now = Date.now(); const accessTokenExpiresAt = new Date(now + Number(tokens.expires_in || 0) * 1000); const refreshTokenExpiresAt = tokens.refresh_expires_in @@ -87,6 +94,8 @@ export async function GET(request: NextRequest) { } catch (err) { console.error('TikTok callback error:', err); 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); } } diff --git a/src/app/(main)/api/tiktok/token/route.ts b/src/app/(main)/api/tiktok/token/route.ts index 9e3724a..18f6a90 100644 --- a/src/app/(main)/api/tiktok/token/route.ts +++ b/src/app/(main)/api/tiktok/token/route.ts @@ -1,5 +1,6 @@ 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) { 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 // it without a configured key. 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 } ); } @@ -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({ - access_token: tokens.accessToken, - open_id: tokens.openId, + connected: true, + accountMatch: true, + brand: TIKTOK_BRAND, + openIdFingerprint: createHash('sha256').update(tokens.openId).digest('hex').slice(0, 12), scope: tokens.scope, - expires_at: tokens.accessTokenExpiresAt.toISOString(), + accessTokenExpiresAt: tokens.accessTokenExpiresAt.toISOString(), + refreshTokenExpiresAt: tokens.refreshTokenExpiresAt?.toISOString() || null, + requiresReconnect: refreshExpired, + uploadMode: 'draft', }); } catch (err) { console.error('TikTok token endpoint error:', err); 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 }); } } diff --git a/src/app/(main)/api/upload/route.ts b/src/app/(main)/api/upload/route.ts index 4336754..cb8a4d4 100644 --- a/src/app/(main)/api/upload/route.ts +++ b/src/app/(main)/api/upload/route.ts @@ -4,6 +4,7 @@ import { authOptions } from '@/lib/auth'; import { uploadFileToR2 } from '@/lib/r2'; import { env } from '@/lib/env'; import { db } from '@/lib/db'; +import { getSessionUserId } from '@/lib/session'; export async function POST(request: NextRequest) { try { @@ -13,7 +14,7 @@ export async function POST(request: NextRequest) { // Fallback: Check for simple-login cookie if no NextAuth session if (!userId) { - const cookieUserId = request.cookies.get('userId')?.value; + const cookieUserId = getSessionUserId(); if (cookieUserId) { // Verify user exists const user = await db.user.findUnique({ diff --git a/src/app/(main)/api/user/debug/route.ts b/src/app/(main)/api/user/debug/route.ts index 2f40a8d..50e88da 100644 --- a/src/app/(main)/api/user/debug/route.ts +++ b/src/app/(main)/api/user/debug/route.ts @@ -1,12 +1,17 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; +import { getSessionUserId } from '@/lib/session'; import { db } from '@/lib/db'; export const dynamic = 'force-dynamic'; export async function GET(request: NextRequest) { 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) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/src/app/(main)/api/user/delete/route.ts b/src/app/(main)/api/user/delete/route.ts index 1b7fdbb..6ab53ff 100644 --- a/src/app/(main)/api/user/delete/route.ts +++ b/src/app/(main)/api/user/delete/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; +import { getSessionUserId } from '@/lib/session'; import { db } from '@/lib/db'; import { stripe } from '@/lib/stripe'; 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) const clientId = userId || getClientIdentifier(request); diff --git a/src/app/(main)/api/user/password/route.ts b/src/app/(main)/api/user/password/route.ts index 2f389bb..7db25a5 100644 --- a/src/app/(main)/api/user/password/route.ts +++ b/src/app/(main)/api/user/password/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; +import { getSessionUserId } from '@/lib/session'; import { db } from '@/lib/db'; import bcrypt from 'bcryptjs'; 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) const clientId = userId || getClientIdentifier(request); diff --git a/src/app/(main)/api/user/plan/route.ts b/src/app/(main)/api/user/plan/route.ts index e525f71..1f3356a 100644 --- a/src/app/(main)/api/user/plan/route.ts +++ b/src/app/(main)/api/user/plan/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; +import { getSessionUserId } from '@/lib/session'; import { db } from '@/lib/db'; import { STRIPE_PLANS } from '@/lib/stripe'; @@ -8,7 +8,7 @@ export const dynamic = 'force-dynamic'; export async function GET(request: NextRequest) { try { // Use cookie-based auth instead of NextAuth - const userId = cookies().get('userId')?.value; + const userId = getSessionUserId(); if (!userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); diff --git a/src/app/(main)/api/user/profile/route.ts b/src/app/(main)/api/user/profile/route.ts index 384b78d..3187d8a 100644 --- a/src/app/(main)/api/user/profile/route.ts +++ b/src/app/(main)/api/user/profile/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; +import { getSessionUserId } from '@/lib/session'; import { db } from '@/lib/db'; import { csrfProtection } from '@/lib/csrf'; import { updateProfileSchema, validateRequest } from '@/lib/validationSchemas'; @@ -13,7 +13,7 @@ export async function PATCH(request: NextRequest) { return NextResponse.json({ error: csrfCheck.error }, { status: 403 }); } - const userId = cookies().get('userId')?.value; + const userId = getSessionUserId(); // Rate Limiting (user-based) const clientId = userId || getClientIdentifier(request); diff --git a/src/app/(main)/api/user/route.ts b/src/app/(main)/api/user/route.ts index 9675d6a..e45d78b 100644 --- a/src/app/(main)/api/user/route.ts +++ b/src/app/(main)/api/user/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; +import { getSessionUserId } from '@/lib/session'; import { db } from '@/lib/db'; // Force dynamic rendering (required for cookies) @@ -11,7 +11,7 @@ export const dynamic = 'force-dynamic'; */ export async function GET(request: NextRequest) { try { - const userId = cookies().get('userId')?.value; + const userId = getSessionUserId(); if (!userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); diff --git a/src/app/(main)/api/user/stats/route.ts b/src/app/(main)/api/user/stats/route.ts index 5535222..3cd0bce 100644 --- a/src/app/(main)/api/user/stats/route.ts +++ b/src/app/(main)/api/user/stats/route.ts @@ -1,12 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; +import { getSessionUserId } from '@/lib/session'; import { db } from '@/lib/db'; export const dynamic = 'force-dynamic'; export async function GET(request: NextRequest) { try { - const userId = cookies().get('userId')?.value; + const userId = getSessionUserId(); if (!userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/src/lib/csrf.ts b/src/lib/csrf.ts index 676c4a9..21db0e5 100644 --- a/src/lib/csrf.ts +++ b/src/lib/csrf.ts @@ -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); } /** diff --git a/src/lib/session-edge.ts b/src/lib/session-edge.ts new file mode 100644 index 0000000..e9d02b9 --- /dev/null +++ b/src/lib/session-edge.ts @@ -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 { + 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; +} diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100644 index 0000000..74abdeb --- /dev/null +++ b/src/lib/session.ts @@ -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: `.`. + */ + +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()); +} diff --git a/src/lib/tiktok.ts b/src/lib/tiktok.ts index f150f75..c4ede73 100644 --- a/src/lib/tiktok.ts +++ b/src/lib/tiktok.ts @@ -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>> | 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 | 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 | 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; try { data = JSON.parse(text) as Record; @@ -116,6 +173,17 @@ export async function tiktokApi(url: string, options: RequestInit = {}) { data = { raw: text }; } + const errorCode = (data?.error as Record | undefined)?.code; + if (errorCode === 'access_token_invalid') { + tokens = await getValidTiktokTokens(true) as NonNullable; + ({ res, text } = await request(tokens.accessToken)); + try { + data = JSON.parse(text) as Record; + } catch { + data = { raw: text }; + } + } + if (!res.ok || (data?.error as Record | undefined)?.code !== 'ok') { const message = (data?.error as Record | undefined)?.message || (typeof data?.raw === 'string' ? data.raw : '') || `TikTok API error: ${res.status}`; throw new TiktokApiError(message, res.status, data); diff --git a/src/middleware.ts b/src/middleware.ts index 4e04268..d30bbce 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -5,6 +5,7 @@ import { buildAttributionSnapshot, serializeAttributionCookie, } from '@/lib/revops'; +import { verifySignedUserIdEdge } from '@/lib/session-edge'; const isProduction = process.env.NODE_ENV === 'production'; @@ -41,7 +42,7 @@ function attachAttributionCookie(req: NextRequest, response: NextResponse) { return response; } -export function middleware(req: NextRequest) { +export async function middleware(req: NextRequest) { const path = req.nextUrl.pathname; 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()); } - // For protected routes, check for userId cookie - const userId = req.cookies.get('userId')?.value; + // For protected routes, require a validly signed userId cookie + const userId = await verifySignedUserIdEdge(req.cookies.get('userId')?.value); if (!userId) { // Not authenticated - redirect to signup