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 →