Skip to content

Portal (Next.js) ​

The portal is the only thing users interact with. It renders every page, protects routes at the edge, and proxies all API calls server-side so the FastAPI service is never exposed to the browser.

Framework and tooling ​

ItemValueSource
FrameworkNext.js 15.5.28 (App Router; pull request 316 upgraded it from 14.2 on 2026-10-23), React 19, TypeScript 5cyberrange/portal/package.json
StylingTailwind CSS 3 with the typography plugin; design tokens in globals.csstailwind.config.js, src/app/globals.css
Auth clientNextAuth 4 (custom Keycloak OAuth provider)src/lib/auth.ts
HTTP clientaxios 1.20 (apiClient, same-origin base URL, 30 s timeout)src/lib/api.ts
Terminal@xterm/xterm 6 with fit and canvas add-onssrc/components/terminal/XtermView.tsx
Markdown guidesreact-markdown, remark-gfm, rehype-sanitizesrc/components/scenario/MarkdownView.tsx
Iconslucide-reactcomponents
TestsJest 30, Testing Library, jsdom (91 suites, 880 tests at 6971373, all passing); Playwright is installed as a dev dependencyjest.config.js, **/*.test.ts(x)
Type check in CInpx tsc --noEmit on every pull request, because Jest's TypeScript transform does not type-check and a type error once broke the release build (pull request 269).github/workflows/portal-typecheck.yml
Dependency auditnpm run audit:prod (npm audit --omit=dev)package.json
Runtimenext start -H 10.115.77.1 -p 3000 as a systemd user unitcyberrange/deploy/systemd/cyberrange-portal.service

Unused dependencies

zustand is listed in package.json but not imported anywhere. guacamole-common-js is imported by components/GuacamoleProvider.tsx (which components/Providers.tsx wraps around every page) and by components/terminal/GuacamoleCanvas.tsx, but no page renders GuacamoleCanvas; the lab terminal uses the WebSocket bridge instead (see D12). next.config.mjs sets eslint.ignoreDuringBuilds: true, so lint errors do not fail a production build.

Response headers and build settings ​

next.config.mjs (pull requests 222, 313, 315 and 316; shown as on main at ba1e1e5):

SettingEffect
redirects()/ answers a real HTTP 307 to /dashboard. Before, app/page.tsx was prerendered as a 200 carrying a client-side redirect, which crashed hydration (React error #310); the page is now force-dynamic and only a fallback
headers(), every other pathContent-Security-Policy: frame-ancestors 'none', X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin
headers(), /lab/dvwa and /lab/dvwa/*X-Frame-Options: SAMEORIGIN, nosniff and the referrer policy, but no CSP from the config: on Next.js 15 a config CSP would replace the route's own, so the DVWA route sets its full sandbox CSP itself
headers(), /api/reviews/:id/images/:imageIdX-Frame-Options: DENY, nosniff, referrer policy; the CSP for uploaded review images is set by apiProxy's binary branch, for the same reason
poweredByHeader: falseNo X-Powered-By: Next.js header
images.unoptimized: trueKeeps the Next.js Image Optimization endpoint off on purpose, since every portal image is a local asset

Project structure ​

text
cyberrange/portal/
├── src/
│   ├── app/                 # App Router pages and API route handlers
│   │   ├── api/             # BFF routes (proxy to FastAPI) + NextAuth
│   │   ├── lab/dvwa/        # DVWA reverse proxy (Scenario 2)
│   │   ├── dashboard/  scenarios/  scenario/[id]/     # student workspace
│   │   ├── instructor/      # instructor pages
│   │   ├── admin/           # admin pages
│   │   ├── orientation/     # first-login orientation (students and instructors)
│   │   └── login/  no-role/  auth-error/  error.tsx  not-found.tsx
│   ├── components/          # UI by feature (auth, scenario, terminal, instructor, admin, ui)
│   ├── hooks/               # data hooks (polling, provisioning, progress, admin, instructor)
│   ├── lib/                 # auth, API client, proxy helpers, role rules, scoring helpers
│   ├── context/             # toast context
│   ├── types/               # NextAuth type extensions
│   └── middleware.ts        # edge route protection
├── public/scenarios/        # student guides (Markdown)
└── next.config.mjs  tailwind.config.js  jest.config.js

Pages and routes ​

RouteWhoPurpose
/anyoneHTTP 307 to /dashboard (middleware then decides)
/loginanyoneSign-in page with Enter Cyber Range (starts the Keycloak flow)
/no-rolesigned in, no app roleAccess-denied page
/auth-erroranyoneNextAuth error page
/orientationstudent, instructor (admins are sent to /admin)First-login orientation with role-specific steps; Continue records it and returns to returnTo
/dashboardstudent (instructors and admins are redirected to their landing page)Student dashboard
/scenariosany app roleMy Labs catalog
/scenario/[id]any app roleLab workspace
/lab/dvwa/[[...path]]any app role, own labDVWA proxy (route handler)
/instructor, /instructor/pods, /instructor/students, /instructor/students/[id], /instructor/reviews, /instructor/reviews/[id]instructor, adminInstructor pages
/admin, /admin/users, /admin/pods, /admin/pods/[id], /admin/system, /admin/auditadminAdmin pages

Role requirements live in one place, src/lib/routeRoles.ts, shared by the edge middleware and the client-side AuthGate so the two cannot drift apart.

Route protection ​

src/middleware.ts runs at the edge for /dashboard, /scenarios, /scenario, /lab, /instructor, /admin (and /progress, /settings, /profile, which have no pages). It:

  1. Decrypts the NextAuth session with NEXTAUTH_SECRET; no session: redirect to /login?callbackUrl=... (path and query).
  2. Requires one of student, instructor, admin; otherwise redirects to /no-role.
  3. Applies path requirements (/admin: admin; /instructor: instructor or admin) and sends instructors and admins away from /dashboard; in both cases the redirect goes to the role's landing page (/admin, /instructor or /dashboard). Steps 2 and 3 are one function, redirectPathForRole(), which AuthGate also calls.

After those checks, OnboardingGate (in src/components/ClientLayout.tsx, so it wraps every page) calls GET /api/onboarding/orientation and redirects a student or instructor who has not finished orientation to /orientation?returnTo=.... Admins, role-less accounts and the /login, /no-role, /auth-error and /orientation pages are not gated. If the check fails, the page shows Orientation check unavailable with Retry instead of its content (src/components/auth/OnboardingGate.tsx).

Hiding a navigation link is not a security boundary; the middleware and, above all, the API guards are. See Authorization Enforcement.

Session handling ​

  • NextAuth session strategy JWT: an encrypted cookie holds the Keycloak access token, refresh token, expiry and roles. maxAge is 12 hours.
  • The jwt callback refreshes the access token 60 seconds before it expires, with a single-flight lock so many concurrent reads trigger only one Keycloak call. On refresh failure the token is marked RefreshAccessTokenError, and the API client sends the user to /login?error=SessionExpired on the next 401.
  • Client components use useSession(); server route handlers use getServerSession(authOptions).
  • session.accessToken is exposed to the browser session because the terminal needs it for its WebSocket URL. See Security Findings.
  • Sign-out goes through /api/auth/federated-logout, which redirects to Keycloak's end-session endpoint and back to the portal.

Source: src/lib/auth.ts, src/lib/api.ts, src/app/api/auth/federated-logout/route.ts.

API communication (backend for frontend) ​

Pages never call FastAPI directly. The chain is:

text
component → hook → lib/api.ts (axios, baseURL /api) → app/api/**/route.ts → proxyToApi() → FastAPI :5000

proxyToApi() in src/lib/apiProxy.ts:

  • rejects requests without a server-side session (401);
  • adds Authorization: Bearer <access token>;
  • forwards only allow-listed query parameters (forwardQuery());
  • supports JSON (default), raw (CSV downloads), binary (images, keeping nosniff and CSP headers) and passBody (multipart uploads);
  • for raw responses passes through only Content-Type, Content-Disposition and the knowledge-gain X-Excluded-Accounts and X-Staff-Exclusion headers;
  • logs upstream failures with only a whitelisted network error code (for example ECONNREFUSED, ETIMEDOUT), never the exception message, and returns degraded responses instead of crashing the page (empty list for GET /pods, otherwise 503).

BFF route groups ​

Portal route prefixBackend routesNotes
/api/pods/*/pods/*provision, list, status, lab URLs, destroy, verify, milestones, alerts, guac-token
/api/progress/*/progress/*progress, reset, flag, rubrics
/api/reviews/*/reviews/*submit, read, resubmit, images
/api/instructor/*/instructor/*pods, students, student diagnostics (pod_id, pod_created_at), labs history, alerts, reviews, resolve, alert snapshot, knowledge-gain export (format, scenario_id, status_filter, anonymize, include_excluded)
/api/admin/*/admin/*users, enable, role, password, MFA, research exclusion (the BFF refuses an ID that is not a 36-character UUID), pods force-destroy, infra health, audit log (event_type, event_types, student_id, result, before_id, limit)
/api/onboarding/orientation/users/me/orientationGET status, POST completion
/api/capacity/capacitycapacity summary
/api/auth/*NextAuthsign-in, callback, session, federated logout
/api/guac-launch/[podId], /api/guac-websocketnone in useLegacy Guacamole routes, still reachable; see Findings

next.config.mjs also rewrites /api/health and /api/score/* to the API and /api/ssh-websocket to the bridge for local development; in production nginx routes the WebSocket.

Error handling ​

LayerBehaviorSource
Route errorsapp/error.tsx shows an error screen with Try Again; app/not-found.tsx for 404src/app/error.tsx, not-found.tsx
Component errorsErrorBoundary catches rendering errors with a retrysrc/components/ErrorBoundary.tsx
API errorsaxios interceptor forces re-login on 401 after a failed refresh; hooks surface messages as toastssrc/lib/api.ts, src/context/ToastContext.tsx
Upstream downBFF returns degraded data or 503 with a readable errorsrc/lib/apiProxy.ts
Lab problemsProvisioning failure phase, "taking longer than usual" note after 75 seconds, session-expired overlaysrc/components/scenario/*

Environment variables (names only) ​

VariablePurpose
NEXTAUTH_URL, NEXTAUTH_SECRETPublic portal URL; key that encrypts sessions
KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRETConfidential OAuth client (portal)
KEYCLOAK_PUBLIC_ISSUER, KEYCLOAK_SERVER_SIDE_ISSUER, KEYCLOAK_ISSUERBrowser-facing issuer, server-side token endpoint base, internal fallback
API_INTERNAL_URLFastAPI base URL (default http://10.115.77.1:5000)
NEXT_PUBLIC_API_URL, NEXT_PUBLIC_SCORING_URLClient-side base URLs (same-origin in production)
BROWSER_SCORE_SECRETShared secret for the DVWA browser-score callback (must match the API)
SSH_BRIDGE_URLBridge URL for the development rewrite

Template: cyberrange/portal/.env.local.example (placeholders only). The real file is portal/.env.local on the host (mode 600).

Sources

cyberrange/portal/package.json, next.config.mjs, src/middleware.ts, src/lib/auth.ts, src/lib/api.ts, src/lib/apiProxy.ts, src/lib/routeRoles.ts, src/components/auth/OnboardingGate.tsx, src/components/ClientLayout.tsx, src/app/**, .github/workflows/portal-typecheck.yml.

Cyber Range capstone documentation (Capstone 2 · Group 2 · A4102). Documents main at commit 6971373.