Identity and Sessions
MOD supports identity and session controls in Keycloak: bounded session lifetimes (NIST AC-11/AC-12), TOTP enrollment enforcement for platform admins (NIST IA-2), an AC-8 system-use notice on the login page, and a dormant x509 smart-card seam. MOD enables operators to configure these controls; it does not make a customer compliant with any framework, and enabling a control here does not by itself satisfy the corresponding control — enrollment coverage, key custody, and the operator's own policy remain the operator's responsibility.
Session lifetimes (AC-11 / AC-12)
CORE/startup/keycloak-init.sh bounds the realm's online session lifetimes in
both the create and the idempotent-update blocks, and converges them on every
run:
| Setting | Value | Control |
|---|---|---|
accessTokenLifespan | 900 s (15 min) | AC-12 access-token ceiling |
ssoSessionIdleTimeout | 1800 s (30 min) | AC-11 inactivity termination (admin-safe realm baseline, owner Q49=A; per-role/per-profile split — users 60m/12h, admins 30m/8h, gov/DoD 15m/8h — comes with FX.54) |
ssoSessionMaxLifespan | 28800 s (8 h) | AC-12 hard session ceiling |
These mirror the platform settings auth.access_token_lifespan_seconds,
auth.sso_session_idle_seconds and auth.sso_session_max_lifetime_seconds
(registered in CORE/API/services/platform_settings.py). The init script pins
the values because it runs inside the Keycloak container and cannot read the API
DB at startup — so the registry and the script must be kept in sync.
Former 30-day client-level session overrides on the dashboard and WorkflowBuilder clients are converged to the same values, so a client can no longer extend a session past the realm ceiling. Offline (refresh-token) lifetimes deliberately stay unbounded — bounding them requires the exchange-token persistence fix first (token rotation would otherwise invalidate every stored token on first use and break long-running workflows).
Admin MFA enforcement (IA-2)
The init script queues the CONFIGURE_TOTP required action on members of the
platform_admin Keycloak group who do not yet carry a TOTP device. On their
next browser login, Keycloak's stock Conditional-OTP step presents the
one-time-password enrollment screen (QR/code) before the dashboard loads, and
every subsequent login asks for the 6-digit code — enrollment is self-enforcing
and no browser-flow surgery is needed.
The queue is idempotent: a user who already completed enrollment (a TOTP device
present, GET /users/{id}/totp returns 200) or already has CONFIGURE_TOTP
queued is skipped, so re-running the init does not force re-enrollment.
Exemptions
Two classes of users are always skipped, because a pending required action breaks non-interactive authentication:
- Members of the
e2e_service_principalsgroup — the E2E harness principals created byCORE/e2e/seed_principals.sh. They authenticate non-interactively (direct grant) and can never complete an OTP enrollment. - Machine users — usernames matching
service-account-*,*@service.local(the live seeded service user isapi@service.local),api_service, andplatform-service. These are client_credentials / direct-grant only and never pass the browser flow; a queuedCONFIGURE_TOTPwould lock them out for no gain. After an apply,api@service.localmust showrequiredActions: [].
MFA for all users
The opt-in platform setting auth.mfa_required_for_all (Admin → Platform
settings, group auth, default false) extends TOTP enforcement to every user
in the realm, machine users and the e2e_service_principals exemption group
still skipped. This is the flip for the gov/DoD buyer tier. Flipping the setting
alone is not enough: set MFA_REQUIRED_FOR_ALL="true" in keycloak-init.sh to
match the registry (the script cannot read the API DB), then re-run the init.
The companion setting auth.mfa_required_for_admins (default ON) covers the
admin-group-only behavior described above.
AC-8 login banner
The platform setting auth.login_banner_text (default empty = no banner) is the
AC-8 system-use notice. keycloak-init.sh converges the realm on every run by
pushing the value into the login theme property themes.login.headerHtml
(HTML-escaped, newlines become <br>), which the modtech login theme renders as
a boxed .modtech-use-notice block at the top of the login card (login and
register pages share the template macro). The fetch-merge-PUT touches only
headerHtml, preserving any other login-theme property an operator set (e.g. a
logo).
Failure mode: if the setting contains raw HTML it is escaped (< renders
literally), never executed as markup.
x509 / smart-card seam
CORE/API/keycloak_auth.py carries an x509_piv_login_enabled seam that is a
no-op while the platform setting auth.x509_enabled is false (the default).
Beyond that seam, no x509/PIV smart-card login path exists in the sources this
page covers — flipping the setting is a future enablement, not a working feature
today.
Session + re-authentication controls (GY.C4b)
Beyond session lifetimes, MOD Core supports three per-account controls, all
tunable as platform settings (Admin → Platform settings, auth group) and all
off by default so an existing install behaves exactly as before:
Concurrent-session cap (auth.session_max_concurrent)
Integer; 0 = unlimited (the default). When set > 0, MOD installs and enables
Keycloak's user-session-limits authenticator in the realm browser
flow (via CORE/API/services/session_controls.py, applied the moment the
setting changes — no Keycloak restart). auth.session_limit_behaviour chooses
what happens when a user exceeds the cap: deny_new (refuse the new login, the
default) or terminate_oldest (end the oldest session to make room).
- No home-grown auth: the limit itself lives in Keycloak; MOD only configures the authenticator through the admin REST API.
- Exempt by construction: service accounts, client-credentials clients and e2e principals never traverse the browser flow, so they are not capped.
- Boot-time install is disabled-by-default:
CORE/startup/keycloak-init.shidempotently installs the execution (requirementDISABLED) so a restart re-converges the shape; the API then flips it on/off to match the live settings. (Keycloak 26.3.x ships the authenticator; the install logs and moves on only if a build/custom realm does not have it — a defensive path.)
Inactive-account auto-disable (auth.inactive_disable_days)
Integer; 0 = off (the default). A daily scheduled sweep
(services/inactive_accounts.py, job inactive_account_disable) soft-
disables human accounts whose last recorded activity is older than N days —
Keycloak enabled=false + MOD status DEACTIVATED, never a delete (ISO 27001
no-hard-delete). Platform owner / break-glass / service accounts are never
touched; each disable is individually audited. auth.inactive_disable_exempt_emails
carries the break-glass / monitored-service carve-out. A dry-run preview —
who the sweep would disable, changing nothing — is at
GET /v1/platform/compliance/inactive-accounts.
Re-authentication for privileged actions (auth.privileged_reauth_max_age_s)
Integer (seconds); 0 = off (the default). Bounds how long a login remains
acceptable for privileged actions — break-glass, compliance-profile apply,
platform-settings writes, user role changes, and API-key creation. The check
reads the Keycloak token's auth_time claim (when the user actually logged
in), so a fresh token on a stale login is still rejected. A stale token returns
401 {"error": "reauth_required", "reauth_max_age_s": N}; the Dashboard
intercepts that error (in CORE/Dashboard/js/api.js) and redirects the browser
through Keycloak with prompt=login&max_age=N to force a fresh credential
check, after which the operator retries the action. Service / api-key channels
carry no human auth_time and are unaffected.
Wording note: MOD supports these controls; it does not claim or certify compliance with any specific framework.
How to re-apply
The init script is idempotent and designed to converge an existing realm; it is
the my_keycloak_init container's command, bind-mounted from
CORE/startup/keycloak-init.sh. Re-run it against the live realm:
cd CORE/startup
docker run --rm --name my_keycloak_init_rerun \
--network mod_modtex-network \
-v "$PWD/keycloak-init.sh:/opt/keycloak/init/keycloak-init.sh:ro" \
-v "$PWD/.env.public:/env/public:ro" -v "$PWD/.env.secrets:/env/secrets:ro" \
--entrypoint bash core-keycloak-init \
-c 'set -a; . /env/public; . /env/secrets; set +a; exec bash /opt/keycloak/init/keycloak-init.sh'
Do not use --env-file: the env files quote their values and
docker run --env-file keeps the quotes literally (compose strips them), so
KEYCLOAK_INTERNAL_URL becomes "http://keycloak:8080" and the run dies at
ERROR: Failed to obtain access token. Sourcing the files in the shell (set -a; . /env/public; . /env/secrets; set +a) strips the quotes the way compose does.
The name my_keycloak_init is held by the original exited init container, hence
the different --name.
Expected log lines: GSEC R-043 — converging login-theme banner,
GSEC R-010 — CONFIGURE_TOTP queued on N user(s) (N = platform_admin members
without a TOTP device), and the summary
GSEC R-009 session policy: access 900s / SSO idle 900s / SSO max 28800s.
After the apply, re-seed the e2e principals (CORE/e2e/seed_principals.sh) so
they land in the exemption group and their passwords converge to the file/env
value.
How to verify each item
- Session bounds — realm API:
→curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \http://keycloak:8080/admin/realms/modtex \| jq '{accessTokenLifespan, ssoSessionIdleTimeout, ssoSessionMaxLifespan, themes}'
900 / 1800 / 28800and the login theme property present. Browser: sign in, leave the tab idle >30 min, then click an API action → bounced to the login page (idle expiry); stay active >8 h → hard-expiry forces re-login. Access tokens in devtools expire at 15 min. - MFA — an admin without a TOTP device signs in → the Conditional-OTP step
presents the enrollment screen before the dashboard loads; every later login
asks for the code. A member of
e2e_service_principalssigns in with no OTP prompt. API check:GET /admin/realms/modtex/users/<admin-id>→requiredActionscontainsCONFIGURE_TOTPbefore enrollment and is empty after; machine users such asapi@service.localmust showrequiredActions: []. - Banner — with a non-empty
auth.login_banner_textpushed to the login theme, the login page renders the notice in the boxed.modtech-use-noticeblock at the top of the card (login and register pages). With the setting empty, the block is absent entirely. - x509 seam — Admin → Platform settings shows
auth.x509_enabled=falseand logins are unchanged. - E2E principals —
CORE/e2e/.auth/e2e_pass.txtexists (24 chars, gitignored) after re-seeding;CORE/e2e/run.shmints all principals non-interactively with no hardcoded fallback password.