Files
openmaic/OpenMAIC/middleware.ts

202 lines
6.9 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { hasDeploymentCapability, resolveDeploymentRole } from '@/lib/config/deployment-role';
import {
isApiPath,
shouldReturnNotFoundAtServerBoundary,
} from '@/lib/config/server-request-boundary';
import { verifyAccessTokenWithWebCrypto } from '@/lib/server/access-token-edge';
import { ACCESS_CODE_COOKIE_NAME } from '@/lib/server/access-token-policy';
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
const OPS_ADMIN_USERNAME = 'jiaoyuop';
function configuredHttpOrigin(name: string): string | null {
const raw = process.env[name]?.trim();
if (!raw) return null;
try {
const url = new URL(raw);
if (
(url.protocol !== 'https:' && url.protocol !== 'http:') ||
url.username ||
url.password ||
url.search ||
url.hash ||
(url.pathname !== '' && url.pathname !== '/')
) {
return null;
}
return url.origin;
} catch {
return null;
}
}
function opsError(status: number, errorCode: string, error: string) {
return NextResponse.json({ success: false, errorCode, error }, { status });
}
async function authorizeOpsApi(request: NextRequest): Promise<NextResponse> {
const worksOrigin = configuredHttpOrigin('WORKS_SQUARE_API_BASE_URL');
const publicOrigin = configuredHttpOrigin('OPS_PUBLIC_ORIGIN');
if (!worksOrigin || !publicOrigin) {
return opsError(503, 'INTERNAL_ERROR', 'Operations identity is not configured');
}
if (!SAFE_METHODS.has(request.method.toUpperCase())) {
const origin = request.headers.get('origin');
try {
if (!origin || new URL(origin).origin !== publicOrigin) {
return opsError(403, 'FORBIDDEN', 'Cross-origin operations request denied');
}
} catch {
return opsError(403, 'FORBIDDEN', 'Cross-origin operations request denied');
}
}
const authorization = request.headers.get('authorization')?.trim() ?? '';
if (!/^Bearer\s+\S+$/.test(authorization) || authorization.length > 8192) {
return opsError(401, 'INVALID_CREDENTIALS', 'Works administrator session required');
}
try {
const response = await fetch(`${worksOrigin}/api/auth/me`, {
method: 'GET',
headers: { Authorization: authorization, Accept: 'application/json' },
redirect: 'error',
cache: 'no-store',
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
return opsError(401, 'INVALID_CREDENTIALS', 'Works administrator session is invalid');
}
const identity = (await response.json()) as { username?: unknown; site_role?: unknown };
if (identity.username !== OPS_ADMIN_USERNAME || identity.site_role !== 'admin') {
return opsError(403, 'FORBIDDEN', 'Works administrator role required');
}
return NextResponse.next();
} catch {
return opsError(503, 'INTERNAL_ERROR', 'Works identity service is unavailable');
}
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const configuredBasePath = process.env.OPENMAIC_BASE_PATH?.replace(/\/+$/, '') || '';
const boundaryPathname =
configuredBasePath &&
(pathname === configuredBasePath || pathname.startsWith(`${configuredBasePath}/`))
? pathname.slice(configuredBasePath.length) || '/'
: pathname;
const role = resolveDeploymentRole(
process.env.OPENMAIC_DEPLOYMENT_ROLE ?? process.env.NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE,
process.env.NODE_ENV,
);
if (shouldReturnNotFoundAtServerBoundary(role, boundaryPathname, request.method)) {
if (isApiPath(boundaryPathname)) {
return NextResponse.json(
{ success: false, errorCode: 'NOT_FOUND', error: 'Not found' },
{ status: 404 },
);
}
return new NextResponse('Not Found', {
status: 404,
headers: {
'content-type': 'text/plain; charset=utf-8',
'x-content-type-options': 'nosniff',
},
});
}
const isOpsPath =
boundaryPathname === '/courses' ||
boundaryPathname.startsWith('/courses/') ||
boundaryPathname === '/publish' ||
boundaryPathname === '/api/courses' ||
boundaryPathname.startsWith('/api/courses/') ||
boundaryPathname === '/api/ops' ||
boundaryPathname.startsWith('/api/ops/');
if (isOpsPath && !hasDeploymentCapability(role, 'manage_courses')) {
if (boundaryPathname.startsWith('/api/')) {
return NextResponse.json(
{
success: false,
errorCode: 'FORBIDDEN',
error: 'Operations capability is disabled',
},
{ status: 403 },
);
}
return NextResponse.redirect(new URL('/', request.url));
}
// Remaining APIs on a dedicated server deployment authenticate at their
// route boundaries. A shared ACCESS_CODE from the ops environment must
// never preempt the internal Bearer publishing contract.
if (role === 'server') {
return NextResponse.next();
}
const isHealthPath =
boundaryPathname === '/api/health' ||
boundaryPathname === '/api/runtime/v1/health/live' ||
boundaryPathname === '/api/runtime/v1/health/ready';
if (role === 'ops') {
if (isHealthPath) return NextResponse.next();
if (isApiPath(boundaryPathname)) return authorizeOpsApi(request);
// Operations pages are authenticated shells. Browser API calls carry the
// Works Bearer and are introspected above; ACCESS_CODE is never consulted.
return NextResponse.next();
}
const accessCode = process.env.ACCESS_CODE;
const worksIdentityConfigured = Boolean(process.env.WORKS_SQUARE_API_BASE_URL?.trim());
if (isOpsPath && worksIdentityConfigured) {
// Pages are public shells; every data route performs server-side Works
// introspection. Bearer sessions are not cookies and must reach the route.
return NextResponse.next();
}
if (isOpsPath && process.env.NODE_ENV === 'production') {
return NextResponse.json(
{
success: false,
errorCode: 'INTERNAL_ERROR',
error: 'Operations deployment is not configured with Works identity',
},
{ status: 503 },
);
}
if (!accessCode) {
return NextResponse.next();
}
// Whitelist: access-code endpoints, health check
if (
boundaryPathname.startsWith('/api/access-code/') ||
isHealthPath
) {
return NextResponse.next();
}
// Check cookie — validate HMAC signature, not just existence
const cookie = request.cookies.get(ACCESS_CODE_COOKIE_NAME);
if (cookie?.value && (await verifyAccessTokenWithWebCrypto(cookie.value, accessCode))) {
return NextResponse.next();
}
// API requests without valid cookie → 401
if (boundaryPathname.startsWith('/api/')) {
return NextResponse.json(
{ success: false, errorCode: 'INVALID_REQUEST', error: 'Access code required' },
{ status: 401 },
);
}
// Page requests → let through, frontend shows modal
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|logos/).*)'],
};