Skip to main content

Token Refresh

Why proactive refresh matters

Epic access tokens expire (typically after 3600 seconds). Without proactive refresh, a clinician mid-consultation would see an unexpected 401 error when their token expires. The TokenRefreshFilter prevents this by refreshing 120 seconds before expiry — the clinician never sees the expiry.

TokenRefreshFilter

TokenRefreshFilter is a OncePerRequestFilter applied to /api/** routes:

@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain) throws IOException, ServletException {
SmartLaunchContext ctx = getContextFromSession(req);
if (ctx != null) {
refreshService.refreshIfNeeded(ctx);
}
chain.doFilter(req, res);
}

TokenRefreshService

@Service
public class TokenRefreshService {

private static final long REFRESH_BUFFER_SECONDS = 120;

public void refreshIfNeeded(SmartLaunchContext ctx) {
Instant threshold = ctx.getExpiresAt()
.minusSeconds(REFRESH_BUFFER_SECONDS);

if (Instant.now().isAfter(threshold)) {
refresh(ctx);
}
}

private void refresh(SmartLaunchContext ctx) {
TokenResponse response = tokenService.refreshTokens(
ctx.getRefreshToken(),
ctx.getFhirBaseUrl());

// Update in place — same session object
ctx.setAccessToken(response.accessToken());
ctx.setExpiresAt(Instant.now()
.plusSeconds(response.expiresIn()));

// Handle refresh token rotation
if (response.refreshToken() != null) {
ctx.setRefreshToken(response.refreshToken());
}
}
}

Refresh token rotation

Epic may return a new refresh token on each refresh (rotation). TokenRefreshService updates ctx.refreshToken when a new one is provided, and retains the old one otherwise.

Session expiry handling

If the refresh token itself is expired (Epic's refresh tokens have their own expiry — typically 8 hours), the token exchange returns an error. TokenRefreshService handles this:

try {
refresh(ctx);
} catch (InvalidGrantException e) {
// Refresh token expired — invalidate session
session.invalidate();
// Caller catches and redirects to /launch?error=session_expired
throw new SessionExpiredException();
}

401 force-refresh

If HAPI FHIR returns 401 despite the proactive refresh (e.g. Epic token was revoked), a FhirClientInterceptor detects the 401 and triggers an immediate refresh before retrying once.


Next: UI Layer →