API Route Authentication and Tenancy Patterns
Overview
This document defines the standard auth and tenancy patterns for Petunia API routes. Following these patterns ensures consistent security across all endpoints.
Last Updated: 2025-11-28 Status: MANDATORY for all new routes, migration in progress for existing routes
Recommended Pattern: withAuth HOC
The withAuth higher-order function from @/lib/auth/route-auth is the recommended pattern for all authenticated API routes.
Basic Usage
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/auth/route-auth';
export const GET = withAuth(async (request, session) => {
// session.user is guaranteed to exist here
const userId = session.user.id;
return NextResponse.json({ userId });
});
What withAuth Provides
- Session validation - Ensures user is authenticated
- User object access -
session.userwith id, email, role, isPlatformAdmin - Onboarding check - Blocks incomplete onboarding users (except for onboarding routes)
- Consistent error responses - Returns 401/403 with proper codes
- Request logging - Logs unauthorized access attempts
Session Object Structure
interface AuthSession {
user: {
id: string;
email: string;
role?: string;
isPlatformAdmin?: boolean;
onboardingCompleted?: boolean;
metadata?: Record<string, unknown>;
};
}
Adding Tenancy Checks
After authentication, you must verify the user has access to the requested tenant's data.
Pattern 1: Using resolveUserPortalContext (Recommended)
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/auth/route-auth';
import { resolveUserPortalContext, PortalAccessError } from '@/lib/server/portal-context';
export const GET = withAuth(async (request, session) => {
const { searchParams } = new URL(request.url);
const petuniaID = searchParams.get('petuniaID');
try {
// Verify user has access to this portal
const portalContext = await resolveUserPortalContext({
userId: session.user.id,
petuniaID,
});
// Now safe to query data for this portal
const data = await getDataForPortal(portalContext.portalId);
return NextResponse.json({ data, portal: portalContext });
} catch (error) {
if (error instanceof PortalAccessError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
throw error;
}
});
Pattern 2: Manual Portal Check (For Simple Cases)
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/auth/route-auth';
import { prisma } from '@/lib/database';
export const GET = withAuth(async (request, session) => {
const { searchParams } = new URL(request.url);
const portalId = searchParams.get('portalId');
if (!portalId) {
return NextResponse.json({ error: 'portalId is required' }, { status: 400 });
}
// Verify user has access to this portal
const access = await prisma.userPortalAccess.findFirst({
where: {
userId: session.user.id,
portalId,
},
});
if (!access && !session.user.isPlatformAdmin) {
return NextResponse.json({ error: 'Access denied to this portal' }, { status: 403 });
}
// Now safe to query data
const data = await prisma.someTable.findMany({
where: { portalId },
});
return NextResponse.json({ data });
});
Pattern 3: Platform Admin Override
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/auth/route-auth';
export const GET = withAuth(async (request, session) => {
// Platform admins can access all portals
if (session.user.isPlatformAdmin) {
const allData = await getAllData();
return NextResponse.json({ data: allData });
}
// Regular users only see their portals
const userPortals = await getUserPortals(session.user.id);
const data = await getDataForPortals(userPortals);
return NextResponse.json({ data });
});
Demo Mode Handling
Always check for demo mode to return mock data instead of querying production:
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/auth/route-auth';
import { isDemoPortal } from '@/lib/demo';
import { resolveUserPortalContext } from '@/lib/server/portal-context';
export const GET = withAuth(async (request, session) => {
const { searchParams } = new URL(request.url);
const petuniaID = searchParams.get('petuniaID');
const portalContext = await resolveUserPortalContext({
userId: session.user.id,
petuniaID,
});
// Return demo data for demo portals
if (isDemoPortal(portalContext.portalId)) {
return NextResponse.json({
data: getDemoData(),
source: 'demo',
});
}
// Query real data for production portals
const data = await getRealData(portalContext.portalId);
return NextResponse.json({
data,
source: 'production',
});
});
Complete Example: Full Implementation
/**
* Example API Route with Full Auth + Tenancy
*
* @version 1.0.0
* @lastModified 2025-11-28
* @author Petunia Team
* @stability stable
*/
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/auth/route-auth';
import { prisma } from '@/lib/database';
import { isDemoPortal } from '@/lib/demo';
import { resolveUserPortalContext, PortalAccessError } from '@/lib/server/portal-context';
import { createLogger } from '@/lib/utils/logging';
const logger = createLogger('example-api');
// Force dynamic rendering
export const dynamic = 'force-dynamic';
export const GET = withAuth(async (request: NextRequest, session) => {
const { searchParams } = new URL(request.url);
const petuniaID = searchParams.get('petuniaID');
// 1. Validate required parameters
if (!petuniaID) {
return NextResponse.json(
{ error: 'petuniaID is required', code: 'MISSING_PORTAL_ID' },
{ status: 400 }
);
}
try {
// 2. Verify portal access
const portalContext = await resolveUserPortalContext({
userId: session.user.id,
petuniaID,
});
logger.info('Accessing data', {
userId: session.user.id,
portalId: portalContext.portalId,
isDemoPortal: portalContext.isDemoPortal,
});
// 3. Handle demo mode
if (portalContext.isDemoPortal) {
return NextResponse.json({
data: { message: 'Demo data' },
source: 'demo',
});
}
// 4. Query real data with tenant filter
const data = await prisma.someTable.findMany({
where: {
portalId: portalContext.portalId,
},
});
return NextResponse.json({
data,
source: 'production',
});
} catch (error) {
if (error instanceof PortalAccessError) {
return NextResponse.json(
{ error: error.message, code: 'ACCESS_DENIED' },
{ status: error.status }
);
}
logger.error('Error fetching data', { error });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
});
export const POST = withAuth(async (request: NextRequest, session) => {
// Same pattern for mutations...
const body = await request.json();
const petuniaID = body.petuniaID;
if (!petuniaID) {
return NextResponse.json({ error: 'petuniaID is required' }, { status: 400 });
}
try {
const portalContext = await resolveUserPortalContext({
userId: session.user.id,
petuniaID,
});
// Block mutations on demo portals
if (portalContext.isDemoPortal) {
return NextResponse.json({
success: true,
message: 'Changes simulated (demo mode)',
});
}
// Perform actual mutation
const result = await prisma.someTable.create({
data: {
...body.data,
portalId: portalContext.portalId,
},
});
return NextResponse.json({ success: true, data: result });
} catch (error) {
if (error instanceof PortalAccessError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
throw error;
}
});
Migration Checklist
When adding auth to an existing route:
- Import
withAuthfrom@/lib/auth/route-auth - Wrap handler function with
withAuth - Add
petuniaIDorportalIdparameter requirement - Use
resolveUserPortalContextfor portal access validation - Handle demo mode with
isDemoPortalcheck - Add proper error responses for access denied
- Update tests to include auth headers
Routes That Don't Need Auth
Some routes are intentionally public:
| Category | Examples | Reason |
|---|---|---|
| Health checks | /api/health, /api/ready | Kubernetes probes |
| Auth endpoints | /api/auth/signin, /api/auth/signup | Pre-authentication |
| Webhooks | /api/webhooks/twilio, /api/webhooks/stripe | External service callbacks |
| Cron jobs | /api/cron/* | Server-to-server with secrets |
| Dev tools | /api/_dev/* | Development only |
Error Response Standards
Always use consistent error response format:
// 400 Bad Request - Missing parameters
return NextResponse.json(
{ error: 'petuniaID is required', code: 'MISSING_PORTAL_ID' },
{ status: 400 }
);
// 401 Unauthorized - No auth
return NextResponse.json(
{ error: 'Authentication required', code: 'AUTH_REQUIRED' },
{ status: 401 }
);
// 403 Forbidden - No access to resource
return NextResponse.json(
{ error: 'Access denied to this portal', code: 'ACCESS_DENIED' },
{ status: 403 }
);
// 404 Not Found - Resource doesn't exist
return NextResponse.json({ error: 'Portal not found', code: 'NOT_FOUND' }, { status: 404 });
Testing Auth
Example test for authenticated route:
import { createMocks } from 'node-mocks-http';
import { GET } from '@/app/api/example/route';
describe('/api/example', () => {
it('returns 401 without auth', async () => {
const { req } = createMocks({ method: 'GET' });
const response = await GET(req);
expect(response.status).toBe(401);
});
it('returns 403 for unauthorized portal', async () => {
// Mock authenticated user without portal access
const { req } = createMocksWithAuth({
method: 'GET',
query: { petuniaID: 'other-portal' },
user: { id: 'user-123' },
});
const response = await GET(req);
expect(response.status).toBe(403);
});
it('returns data for authorized portal', async () => {
// Mock authenticated user with portal access
const { req } = createMocksWithAuth({
method: 'GET',
query: { petuniaID: 'my-portal' },
user: { id: 'user-123' },
});
const response = await GET(req);
expect(response.status).toBe(200);
});
});