202 lines
6.9 KiB
TypeScript
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/).*)'],
|
|
};
|