Skip to main content

Session & Security

Filter chain order

ImmunizationConfig.securityFilterChain() registers the Spring Security chain with two custom filters inserted at specific positions:

Request

├─ CsrfFilter (CSRF token check — disabled for /api/**)
├─ SessionManagementFilter (session fixation: newSession on auth)

├─ SmartSessionFilter ← inserted before UsernamePasswordAuthenticationFilter
│ checks SmartLaunchContext in session
│ sets Spring Security Authentication from context

├─ TokenRefreshFilter ← inserted after SmartSessionFilter
│ fires only on /api/** requests
│ calls SmartLaunchService.refreshIfNeeded()

└─ DispatcherServlet

Public vs protected routes

SmartSessionFilter maintains an explicit allowlist:

private static final List<String> PUBLIC_PATHS = List.of(
"/launch", "/callback", "/error",
"/actuator/health",
"/css", "/js", "/img", "/favicon.ico"
);

Every route not in this list requires a valid SmartLaunchContext in the session. The Spring Security config backs this up:

.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/launch", "/callback", "/error",
"/actuator/health",
"/css/**", "/js/**", "/img/**", "/favicon.ico"
).permitAll()
.anyRequest().authenticated()
)

Having both layers (filter check + Spring authorisation) ensures a missing or expired session redirects to /launch?error=... rather than returning a 401 JSON error, which is the correct UX for a browser app.

SmartSessionFilter logic

Incoming request

├─ isPublic(path)? → chain.doFilter() (pass through)

├─ session == null? → redirect /launch?error=no_session

├─ SmartLaunchContext == null? → redirect /launch?error=no_context

├─ ctx.isExpired() && refreshToken == null?
│ → session.invalidate()
│ → redirect /launch?error=session_expired

├─ Set Spring Security Authentication
│ principal = clinicianName ?? patientId
│ authorities = [ROLE_CLINICIAN]

└─ chain.doFilter() (continue)

The filter does not check ctx.isExpired() when a refresh token is present — that is TokenRefreshFilter's responsibility. This avoids a race condition where SmartSessionFilter redirects on expiry at the same moment TokenRefreshFilter would have refreshed successfully.

TokenRefreshFilter logic

Fires only on /api/** requests (JSON endpoints used by Thymeleaf fetch calls and external integrations):

if (req.getRequestURI().startsWith("/api/")) {
SmartLaunchContext ctx = session.getAttribute(CTX_KEY);
if (ctx != null) {
launchService.refreshIfNeeded(ctx);
}
}

SmartLaunchService.refreshIfNeeded() checks:

Instant threshold = ctx.getExpiresAt()
.minusSeconds(props.tokenRefreshBufferSeconds()); // default 120s

if (Instant.now().isAfter(threshold)) {
// POST /oauth2/token?grant_type=refresh_token
ctx.setAccessToken(newToken);
ctx.setExpiresAt(Instant.now().plusSeconds(expiresIn));
// Rotate refresh token if new one returned
if (newRefreshToken != null) ctx.setRefreshToken(newRefreshToken);
}

The 120-second buffer means: if a clinician has 1 hour 58 minutes into a session and clicks a FHIR data button, the token is refreshed silently before the HAPI request fires. They never see a 401.

Session fixation protection

.sessionManagement(sm -> sm
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.sessionFixation().newSession() // new session ID on each auth
.maximumSessions(3)
)

newSession() replaces the session ID after authentication — prevents a pre-auth session ID from being used post-auth. maximumSessions(3) limits concurrent sessions per clinician identity.

CSRF

CSRF protection is enabled for browser-facing routes (forms in Thymeleaf templates use th:action which injects the CSRF token automatically). It is disabled for /api/**:

.csrf(csrf -> csrf.ignoringRequestMatchers("/api/**"))

The /api/** endpoints require an active SmartLaunchContext in the session — they cannot be hit by a cross-site request without an active session, making CSRF protection redundant for them.

Content Security Policy

.headers(h -> h
.contentSecurityPolicy(csp ->
csp.policyDirectives("default-src 'self'; script-src 'self' 'unsafe-inline'"))
.frameOptions(fo -> fo.sameOrigin())
)

'unsafe-inline' is required for the inline JavaScript in the dashboard template (session countdown timer). In production, replace with a nonce-based CSP.


Next: UI Views →