import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { jwtVerify, SignJWT } from 'jose' import { type NextRequest, NextResponse } from 'next/server' import { env } from '@/lib/core/config/env' import { getClientIp } from '@/lib/core/utils/request' const logger = createLogger('CronAuth') const getJwtSecret = () => { // Prefer a dedicated JWT signing key so the internal-JWT trust domain is // separable from the raw INTERNAL_API_SECRET shared-bearer secret: leaking one // shouldn't grant the other (raw secret => call internal endpoints; JWT key => // mint tokens for arbitrary userIds). Falls back to INTERNAL_API_SECRET when // unset so existing deployments keep working until the key is rotated in. const secret = new TextEncoder().encode(env.INTERNAL_JWT_SECRET || env.INTERNAL_API_SECRET) return secret } /** * Generate an internal JWT token for server-side API calls * Token expires in 5 minutes to keep it short-lived * @param userId Optional user ID to embed in token payload */ export async function generateInternalToken(userId?: string): Promise { const secret = getJwtSecret() const payload: { type: string; userId?: string } = { type: 'internal' } if (userId) { payload.userId = userId } const token = await new SignJWT(payload) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() .setExpirationTime('5m') .setIssuer('sim-internal') .setAudience('sim-api') .sign(secret) return token } /** * Verify an internal JWT token * Returns verification result with userId if present in token */ export async function verifyInternalToken( token: string ): Promise<{ valid: boolean; userId?: string }> { try { const secret = getJwtSecret() const { payload } = await jwtVerify(token, secret, { issuer: 'sim-internal', audience: 'sim-api', }) // Check that it's an internal token if (payload.type === 'internal') { return { valid: true, userId: typeof payload.userId === 'string' ? payload.userId : undefined, } } return { valid: false } } catch (error) { // Token verification failed return { valid: false } } } /** * Verify CRON authentication for scheduled API endpoints * Returns null if authorized, or a NextResponse with error if unauthorized */ export function verifyCronAuth(request: NextRequest, context?: string): NextResponse | null { if (!env.CRON_SECRET) { const contextInfo = context ? ` for ${context}` : '' logger.warn(`CRON endpoint accessed but CRON_SECRET is not configured${contextInfo}`, { ip: getClientIp(request), userAgent: request.headers.get('user-agent') ?? 'unknown', context, }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const authHeader = request.headers.get('authorization') const expectedAuth = `Bearer ${env.CRON_SECRET}` const isValid = authHeader !== null && safeCompare(authHeader, expectedAuth) if (!isValid) { const contextInfo = context ? ` for ${context}` : '' logger.warn(`Unauthorized CRON access attempt${contextInfo}`, { hasAuthorizationHeader: authHeader !== null, ip: getClientIp(request), userAgent: request.headers.get('user-agent') ?? 'unknown', context, }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } return null }