Appearance
Login, Sessions & Tokens
This page covers authentication: how a user proves who they are and how that identity travels through the system. What each role may do is covered in Authorization Enforcement and User Roles.
Identity provider
| Setting | Value | Source |
|---|---|---|
| Product | Keycloak, running natively in the guacamole container | cyberrange/deploy/keycloak/keycloak.service |
| Start command | kc.sh start-dev --http-host=127.0.0.1 --http-port=8083 --proxy-headers=xforwarded --http-relative-path=/auth --import-realm | same |
| Public path | https://<portal host>/auth/ through the gateway nginx | cyberrange/deploy/guacamole/nginx-ampere.conf |
| Realm | cyber-range | cyberrange/deploy/host/create_keycloak_realm.sh |
| Client used by the portal and API | portal, confidential (has a client secret), standard flow enabled, service accounts enabled, direct access grants enabled | same |
| Client used by admin user management | cyberrange-user-admin service account, realm-management view-users, query-users, manage-users | cyberrange/deploy/host/setup_user_admin_client.sh |
| Application roles | Realm roles student, instructor, admin | cyberrange/deploy/host/create_demo_accounts.sh |
| Second factor (SEC-03) | Required for every user at browser sign-in: TOTP, HmacSHA1, 6 digits, 30-second period. The browser flow's OTP sub-flow and OTP Form are REQUIRED and the "user configured" condition is disabled, so a user with no authenticator is sent through QR enrolment (CONFIGURE_TOTP) on their next sign-in and cannot fall back to password-only | cyberrange/deploy/host/enable_keycloak_mfa.sh |
| Password policy (SEC-04) | length(8) and maxLength(128) and notUsername and notEmail and passwordHistory(3) and passwordBlacklist(cyberrange-common-passwords.txt); no composition rules and no expiry, following NIST SP 800-63B-4 and OWASP ASVS | cyberrange/deploy/host/enable_keycloak_password_policy.sh |
| Dictionary-word rule (retired) | Not active. A custom dictionaryWords(scowl-en-v1) provider (pull request 177; single-letter words a, i, o allowed by pull request 199) refused any English word of two or more letters inside a password. Pull request 249 removed it from the realm policy so users can choose their own passwords and passphrases: enable_keycloak_password_policy.sh now fails unless dictionaryWords is absent, and push_keycloak_realm.sh refuses a realm export that still contains it. The provider JAR is still installed by install_keycloak_unit.sh and push_keycloak_realm.sh for old snapshots, but is inert unless a realm enables it | cyberrange/deploy/keycloak/dictionary-policy/README.md, install_keycloak_dictionary_policy.sh |
| Common-password blocklist | 2,112 entries: the SecLists 10k list lower-cased with entries under 8 characters dropped, plus project words; installed into the Keycloak container (also by push_keycloak_realm.sh, so a realm import finds it) | cyberrange/deploy/keycloak/password-blacklists/, install_keycloak_blocklist.sh |
| Brute-force protection (SEC-04) | Temporary lockout only: after 10 failures, a wait that starts at 1 minute and grows to 15 minutes; permanentLockout=false, so no Admin is needed to unlock | enable_keycloak_password_policy.sh |
| Login theme | cyberrange, extending the built-in keycloak theme: MMDC logo, brand colour, numbered authenticator-setup page and a numeric one-time-code page. Form field names are unchanged from Keycloak 25.0.6 | cyberrange/deploy/keycloak/themes/cyberrange/login/, install_keycloak_theme.sh |
| Password strength meter | The theme loads js/password-strength.js on Keycloak's password pages (including the forced change after a temporary password): a Weak / Medium / Good / Strong bar and live "Must be 8–128 characters" and "Must not match your account name" rows. It is advisory only; Keycloak still decides on save, and common passwords and history are checked only then. The portal's Admin dialogs use the same estimate (lib/passwordStrength.ts, kept in sync by comment) | themes/cyberrange/login/theme.properties, resources/js/password-strength.js, cyberrange/portal/src/lib/passwordStrength.ts |
| Bootstrap admin | KEYCLOAK_ADMIN / KEYCLOAK_ADMIN_PASSWORD in /etc/keycloak/admin.env inside the container (template: deploy/keycloak/admin.env.example) | install_keycloak_unit.sh |
Development mode in production
The unit runs kc.sh start-dev, Keycloak's development mode, and no KC_DB database is configured. In that mode Keycloak uses an embedded H2 file database and relaxed development defaults. See Security Findings and Database.
These are realm settings, not release code
The release pipeline does not run the Keycloak scripts. Each host runs enable_keycloak_mfa.sh, install_keycloak_theme.sh and enable_keycloak_password_policy.sh once; each is idempotent, re-reads the realm and fails closed unless the settings stuck (MFA_ENFORCED, THEME_OK, PASSWORD_POLICY_SETUP_OK). verify_sec03_mfa.sh and verify_password_policy.sh produce sanitized evidence. verify_password_policy.sh now also expects a password with an embedded ordinary word and a multi-word passphrase to be accepted (pull request 249). MFA was applied to the live range on 2026-10-05 (pull request 146). Whether the current password policy (with the dictionary rule removed) has been applied live is Needs Verification: pull requests 170 and 249 merged with unit tests only, and the realm must be re-exported after the change.
Login flow
Key details (cyberrange/portal/src/lib/auth.ts):
- Provider: a custom NextAuth OAuth provider with id
keycloak, scopeopenid email profile,checks: ["pkce", "state"]. - Split issuers: the browser is sent to
KEYCLOAK_PUBLIC_ISSUER(the public/auth/...URL); the portal server exchanges codes, refreshes tokens and fetches keys atKEYCLOAK_SERVER_SIDE_ISSUER. Theissuerchecked in tokens is the public one. Endpoints are configured explicitly instead of using discovery, because discovery on the internal port returned loopback URLs. - Profile mapping: the user's display name and student ID come from
preferred_username, the same key the API uses. - Landing page: after sign-in
/loginroutes by role (admin to/admin, instructor to/instructor, student to/dashboard) and only honors acallbackUrlthat is same-origin and allowed for the role (resolveSameOriginPath()inlib/loginRouting.ts, which also defeats//evil.exampleand backslash tricks). Since pull request 176 an instructor or admin who opens/dashboardlater is also sent to their own landing page (redirectPathForRole()inlib/routeRoles.ts). - First-login orientation:
OnboardingGate(wrapped around every page incomponents/ClientLayout.tsx) asks the API whether the signed-in user has finished orientation (GET /users/me/orientation, stored per Keycloaksubin theuser_onboardingtable). If not, it redirects students and instructors to/orientation?returnTo=<page>; Continue records completion and returns them. Admins skip it (pull request 311), and/login,/no-role,/auth-errorand/orientationitself are never gated. If the check fails, the page shows Orientation check unavailable with a Retry button instead of the content (components/auth/OnboardingGate.tsx,app/orientation/page.tsx,onboardingRedirectPath()inlib/routeRoles.ts,cyberrange/src/provisioning/onboarding_router.py).
Sessions and tokens
| Item | Where it lives | Lifetime | Notes |
|---|---|---|---|
| Portal session | Encrypted NextAuth JWT cookie (NEXTAUTH_SECRET) | 12 hours (session.maxAge) | Default cookie attributes: SameSite=Lax, HttpOnly, Secure when NEXTAUTH_URL is https |
| Keycloak access token | Inside the session; also placed on the client session object as session.accessToken | Keycloak realm setting (the code comment says it matches 12 h) | Sent as Authorization: Bearer to the API and in the terminal WebSocket URL |
| Refresh token | Inside the encrypted session only | Keycloak setting | Rotated by Keycloak; the new one is stored |
| ID token | Inside the encrypted session JWT only, never copied to the client session object | Replaced with the newest one on each refresh | Used only as id_token_hint at logout (added by pull request 146) |
| Roles | Inside the session (token.roles) and on session.user.roles | Re-read at every refresh | Decoded from realm_access.roles |
Why cookies stay same-site
Keycloak is served at /auth/ on the same hostname as the portal, so the OAuth callback is a same-site navigation and the default SameSite=Lax cookies work. A comment in lib/auth.ts warns against switching to SameSite=None.
Token refresh
The NextAuth jwt callback reuses the access token until 60 seconds before it expires, then calls Keycloak's token endpoint with grant_type=refresh_token. Concurrent requests share one refresh (single-flight map keyed by the refresh token). Roles are decoded again from the new token, so an admin's role change reaches the portal at the next refresh (proven by cyberrange/deploy/host/verify_role_refresh.sh). A failed refresh marks the session with RefreshAccessTokenError; the next API 401 sends the user to /login?error=SessionExpired.
Authenticated API request
- The API does not verify JWT signatures locally; Keycloak's introspection answer is authoritative (
cyberrange/src/provisioning/auth.py). - Cache keys are the SHA-256 of the token, so raw tokens are not stored as dictionary keys; entries live at most 60 seconds or until the token's own expiry, whichever is sooner (
introspect_cache.py). - Keycloak errors produce 503
Auth service unavailable, never a silent pass.
Terminal authentication
The WebSocket carries the access token in its URL: /api/ssh-websocket?token=...&pod_id=...&pod_type=.... The bridge introspects it (sharing the same cache module) and then calls GET /pods/{id}/status with it to confirm the caller may use that lab. Details and security notes: Terminal Access.
Logout
The Log out and Sign out buttons navigate straight to /api/auth/federated-logout (components/layout/TopNav.tsx, app/no-role/page.tsx). The route reads the ID token from the server-side session with getToken, clears the NextAuth session cookie (including its numbered chunks), and redirects to Keycloak's .../protocol/openid-connect/logout with id_token_hint, client_id and post_logout_redirect_uri. With the hint, Keycloak ends that exact session and returns the user to Enter Cyber Range without its "Do you want to log out?" page (cyberrange/portal/src/app/api/auth/federated-logout/route.ts).
Before pull request 146 the buttons called NextAuth signOut(), which deleted the session cookie before this route ran, and the ID token was never stored, so the hint was never sent (former finding F18). A session that began before the change still shows Keycloak's question once. The route is a GET, so a cross-site link could sign a user out; that is low impact and was left as is.
Security boundaries (authentication)
- Passwords and one-time codes are only ever typed into Keycloak, never into the portal.
- Every browser sign-in needs the password and a TOTP code. A user who loses their phone needs an Admin to Reset MFA (
DELETE /admin/users/{id}/mfa), which deletes only their OTP credentials; there is no self-service recovery. - Repeated wrong passwords lock the account temporarily (10 failures, 1 to 15 minutes).
- Codes are exchanged server-side with a client secret and PKCE.
- The API trusts nothing from the client about identity: the student ID always comes from
preferred_usernamein the introspection result. The only exception isAUTH_ENABLED=false, a bootstrap mode that logs a CRITICAL "full authorization bypass" warning at startup. - Disabled users and role changes take effect within 60 seconds at the API, immediately when done from the Admin page (cache invalidation), and at the next token refresh in the portal.
- Direct access grants (password grant) are still enabled on the
portalclient and were deliberately not changed by the MFA work. Theverify_*scripts now use a separate confidentialcyberrange-verifyclient, whose flow denies any user without thecyberrange-verifymarker role (setup_verify_accounts.sh,cyberrange/docs/SEC-03-verify-accounts.md). See F5.
Sources
cyberrange/portal/src/lib/auth.ts, lib/loginRouting.ts, lib/routeRoles.ts, lib/api.ts, lib/passwordStrength.ts, components/auth/OnboardingGate.tsx, app/orientation/page.tsx, app/api/auth/federated-logout/route.ts, components/layout/TopNav.tsx, cyberrange/src/provisioning/onboarding_router.py, cyberrange/deploy/host/enable_keycloak_mfa.sh, verify_sec03_mfa.sh, install_keycloak_theme.sh, enable_keycloak_password_policy.sh, install_keycloak_blocklist.sh, install_keycloak_dictionary_policy.sh, push_keycloak_realm.sh, verify_password_policy.sh, cyberrange/deploy/keycloak/dictionary-policy/README.md, setup_verify_accounts.sh, cyberrange/deploy/keycloak/themes/cyberrange/, cyberrange/src/provisioning/auth.py, introspect_cache.py, cyberrange/deploy/keycloak/keycloak.service, cyberrange/deploy/host/create_keycloak_realm.sh, create_demo_accounts.sh, setup_user_admin_client.sh, verify_role_refresh.sh, cyberrange/docs/AUTH-03-role-contract.md.