156 lines
5.0 KiB
TypeScript
156 lines
5.0 KiB
TypeScript
import type { NextRequest } from 'next/server';
|
|
import { resolveDeploymentRole, hasDeploymentCapability } from '@/lib/config/deployment-role';
|
|
import { verifyAccessToken } from '@/lib/server/access-token';
|
|
import { ACCESS_CODE_COOKIE_NAME } from '@/lib/server/access-token-policy';
|
|
import { apiError, API_ERROR_CODES } from '@/lib/server/api-response';
|
|
|
|
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
export const OPS_PUBLIC_ORIGIN_ENV = 'OPS_PUBLIC_ORIGIN';
|
|
export const WORKS_SQUARE_API_BASE_URL_ENV = 'WORKS_SQUARE_API_BASE_URL';
|
|
export const WORKS_OPERATIONS_ADMIN_USERNAME = 'jiaoyuop';
|
|
|
|
function configuredOpsOrigin(): string | null | undefined {
|
|
const configured = process.env[OPS_PUBLIC_ORIGIN_ENV]?.trim();
|
|
if (!configured) return undefined;
|
|
try {
|
|
const url = new URL(configured);
|
|
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 expectedOpsOrigin(request: Request): string | null {
|
|
const configured = configuredOpsOrigin();
|
|
if (configured !== undefined) return configured;
|
|
try {
|
|
return new URL(request.url).origin;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function hasValidOpsMutationOrigin(request: Request): boolean {
|
|
if (SAFE_METHODS.has(request.method.toUpperCase())) return true;
|
|
|
|
const origin = request.headers.get('origin');
|
|
if (!origin) return false;
|
|
try {
|
|
const expected = expectedOpsOrigin(request);
|
|
return expected !== null && new URL(origin).origin === expected;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function getServerDeploymentRole() {
|
|
return resolveDeploymentRole(
|
|
process.env.OPENMAIC_DEPLOYMENT_ROLE ?? process.env.NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE,
|
|
process.env.NODE_ENV,
|
|
);
|
|
}
|
|
|
|
export function isOpsDeployment(role = getServerDeploymentRole()): boolean {
|
|
return hasDeploymentCapability(role, 'manage_courses');
|
|
}
|
|
|
|
/**
|
|
* First-stage operations authorization.
|
|
*
|
|
* Deployment role limits which instance can host the workbench. ACCESS_CODE
|
|
* then authenticates the browser session. A future account/role system can
|
|
* replace this helper without changing every course route again.
|
|
*/
|
|
export function configuredWorksSquareApiOrigin(): string | null {
|
|
const raw = process.env[WORKS_SQUARE_API_BASE_URL_ENV]?.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;
|
|
}
|
|
}
|
|
|
|
async function authorizeWithWorksSession(request: NextRequest, origin: string) {
|
|
const authorization = request.headers.get('authorization')?.trim() ?? '';
|
|
if (!/^Bearer\s+\S+$/.test(authorization) || authorization.length > 8192) {
|
|
return apiError(API_ERROR_CODES.INVALID_CREDENTIALS, 401, 'Works administrator session required');
|
|
}
|
|
try {
|
|
const response = await fetch(`${origin}/api/auth/me`, {
|
|
method: 'GET',
|
|
headers: { Authorization: authorization, Accept: 'application/json' },
|
|
redirect: 'error',
|
|
cache: 'no-store',
|
|
signal: AbortSignal.timeout(5000),
|
|
});
|
|
if (!response.ok) {
|
|
return apiError(API_ERROR_CODES.INVALID_CREDENTIALS, 401, 'Works administrator session is invalid');
|
|
}
|
|
const identity = (await response.json()) as { username?: unknown; site_role?: unknown };
|
|
if (
|
|
identity.username !== WORKS_OPERATIONS_ADMIN_USERNAME ||
|
|
identity.site_role !== 'admin'
|
|
) {
|
|
return apiError(API_ERROR_CODES.FORBIDDEN, 403, 'Works administrator role required');
|
|
}
|
|
return null;
|
|
} catch {
|
|
return apiError(API_ERROR_CODES.INTERNAL_ERROR, 503, 'Works identity service is unavailable');
|
|
}
|
|
}
|
|
|
|
export async function requireOpsAccess(request: NextRequest) {
|
|
if (!isOpsDeployment()) {
|
|
return apiError(API_ERROR_CODES.FORBIDDEN, 403, 'Operations capability is disabled');
|
|
}
|
|
|
|
if (!hasValidOpsMutationOrigin(request)) {
|
|
return apiError(API_ERROR_CODES.FORBIDDEN, 403, 'Cross-origin operations request denied');
|
|
}
|
|
|
|
const worksOrigin = configuredWorksSquareApiOrigin();
|
|
if (worksOrigin) return authorizeWithWorksSession(request, worksOrigin);
|
|
|
|
// Production Learning Ops always delegates identity to Works. The legacy
|
|
// ACCESS_CODE fallback is deliberately development-only.
|
|
if (process.env.NODE_ENV === 'production') {
|
|
return apiError(
|
|
API_ERROR_CODES.INTERNAL_ERROR,
|
|
503,
|
|
`Operations identity is not configured (${WORKS_SQUARE_API_BASE_URL_ENV})`,
|
|
);
|
|
}
|
|
|
|
const accessCode = process.env.ACCESS_CODE;
|
|
if (!accessCode) return null;
|
|
|
|
const token = request.cookies.get(ACCESS_CODE_COOKIE_NAME)?.value;
|
|
if (!token || !verifyAccessToken(token, accessCode)) {
|
|
return apiError(API_ERROR_CODES.INVALID_CREDENTIALS, 401, 'Operations access required');
|
|
}
|
|
|
|
return null;
|
|
}
|