Appearance
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
| Item | Value | Source |
|---|---|---|
| Framework | Next.js 15.5.28 (App Router; pull request 316 upgraded it from 14.2 on 2026-10-23), React 19, TypeScript 5 | cyberrange/portal/package.json |
| Styling | Tailwind CSS 3 with the typography plugin; design tokens in globals.css | tailwind.config.js, src/app/globals.css |
| Auth client | NextAuth 4 (custom Keycloak OAuth provider) | src/lib/auth.ts |
| HTTP client | axios 1.20 (apiClient, same-origin base URL, 30 s timeout) | src/lib/api.ts |
| Terminal | @xterm/xterm 6 with fit and canvas add-ons | src/components/terminal/XtermView.tsx |
| Markdown guides | react-markdown, remark-gfm, rehype-sanitize | src/components/scenario/MarkdownView.tsx |
| Icons | lucide-react | components |
| Tests | Jest 30, Testing Library, jsdom (91 suites, 880 tests at 6971373, all passing); Playwright is installed as a dev dependency | jest.config.js, **/*.test.ts(x) |
| Type check in CI | npx 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 audit | npm run audit:prod (npm audit --omit=dev) | package.json |
| Runtime | next start -H 10.115.77.1 -p 3000 as a systemd user unit | cyberrange/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):
| Setting | Effect |
|---|---|
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 path | Content-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/:imageId | X-Frame-Options: DENY, nosniff, referrer policy; the CSP for uploaded review images is set by apiProxy's binary branch, for the same reason |
poweredByHeader: false | No X-Powered-By: Next.js header |
images.unoptimized: true | Keeps 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.jsPages and routes
| Route | Who | Purpose |
|---|---|---|
/ | anyone | HTTP 307 to /dashboard (middleware then decides) |
/login | anyone | Sign-in page with Enter Cyber Range (starts the Keycloak flow) |
/no-role | signed in, no app role | Access-denied page |
/auth-error | anyone | NextAuth error page |
/orientation | student, instructor (admins are sent to /admin) | First-login orientation with role-specific steps; Continue records it and returns to returnTo |
/dashboard | student (instructors and admins are redirected to their landing page) | Student dashboard |
/scenarios | any app role | My Labs catalog |
/scenario/[id] | any app role | Lab workspace |
/lab/dvwa/[[...path]] | any app role, own lab | DVWA proxy (route handler) |
/instructor, /instructor/pods, /instructor/students, /instructor/students/[id], /instructor/reviews, /instructor/reviews/[id] | instructor, admin | Instructor pages |
/admin, /admin/users, /admin/pods, /admin/pods/[id], /admin/system, /admin/audit | admin | Admin 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:
- Decrypts the NextAuth session with
NEXTAUTH_SECRET; no session: redirect to/login?callbackUrl=...(path and query). - Requires one of
student,instructor,admin; otherwise redirects to/no-role. - 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,/instructoror/dashboard). Steps 2 and 3 are one function,redirectPathForRole(), whichAuthGatealso 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.
maxAgeis 12 hours. - The
jwtcallback 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 markedRefreshAccessTokenError, and the API client sends the user to/login?error=SessionExpiredon the next 401. - Client components use
useSession(); server route handlers usegetServerSession(authOptions). session.accessTokenis 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 :5000proxyToApi() 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, keepingnosniffand CSP headers) andpassBody(multipart uploads); - for
rawresponses passes through onlyContent-Type,Content-Dispositionand the knowledge-gainX-Excluded-AccountsandX-Staff-Exclusionheaders; - 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 forGET /pods, otherwise 503).
BFF route groups
| Portal route prefix | Backend routes | Notes |
|---|---|---|
/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/orientation | GET status, POST completion |
/api/capacity | /capacity | capacity summary |
/api/auth/* | NextAuth | sign-in, callback, session, federated logout |
/api/guac-launch/[podId], /api/guac-websocket | none in use | Legacy 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
| Layer | Behavior | Source |
|---|---|---|
| Route errors | app/error.tsx shows an error screen with Try Again; app/not-found.tsx for 404 | src/app/error.tsx, not-found.tsx |
| Component errors | ErrorBoundary catches rendering errors with a retry | src/components/ErrorBoundary.tsx |
| API errors | axios interceptor forces re-login on 401 after a failed refresh; hooks surface messages as toasts | src/lib/api.ts, src/context/ToastContext.tsx |
| Upstream down | BFF returns degraded data or 503 with a readable error | src/lib/apiProxy.ts |
| Lab problems | Provisioning failure phase, "taking longer than usual" note after 75 seconds, session-expired overlay | src/components/scenario/* |
Environment variables (names only)
| Variable | Purpose |
|---|---|
NEXTAUTH_URL, NEXTAUTH_SECRET | Public portal URL; key that encrypts sessions |
KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET | Confidential OAuth client (portal) |
KEYCLOAK_PUBLIC_ISSUER, KEYCLOAK_SERVER_SIDE_ISSUER, KEYCLOAK_ISSUER | Browser-facing issuer, server-side token endpoint base, internal fallback |
API_INTERNAL_URL | FastAPI base URL (default http://10.115.77.1:5000) |
NEXT_PUBLIC_API_URL, NEXT_PUBLIC_SCORING_URL | Client-side base URLs (same-origin in production) |
BROWSER_SCORE_SECRET | Shared secret for the DVWA browser-score callback (must match the API) |
SSH_BRIDGE_URL | Bridge 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.