Medium severityNVD Advisory· Published Apr 21, 2026· Updated Apr 29, 2026
CVE-2026-40939
CVE-2026-40939
Description
The Data Sharing Framework (DSF) implements a distributed process engine based on the BPMN 2.0 and FHIR R4 standards. Prior to 2.1.0, OIDC-authenticated sessions had no configured maximum inactivity timeout. Sessions persisted indefinitely after login, even after the OIDC access token expired. This vulnerability is fixed in 2.1.0.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
dev.dsf:dsf-common-jettyMaven | >= 0 | — |
dev.dsf:dsf-fhir-serverMaven | >= 0 | — |
dev.dsf:dsf-bpe-serverMaven | >= 0 | — |
Affected products
1Patches
27d25feafb83dImproved websocket session handling
21 files changed · +237 −124
dsf-bpe/dsf-bpe-server/src/main/java/dev/dsf/bpe/authentication/IdentityProviderImpl.java+3 −3 modified@@ -80,14 +80,14 @@ public Identity getIdentity(X509Certificate[] certificates) Organization o = localOrganization.get(); Endpoint e = localEndpoint.get(); - return new PractitionerIdentityImpl(o, e, getDsfRolesFor(p, certWrapper.thumbprint(), null, null), - certWrapper, p, getPractitionerRolesFor(p, certWrapper.thumbprint(), null, null), null); + return new PractitionerIdentityImpl(o, e, getDsfRolesFor(p, certWrapper.getThumbprint(), null, null), + certWrapper, p, getPractitionerRolesFor(p, certWrapper.getThumbprint(), null, null), null); } else { logger.warn( "Certificate with thumbprint '{}' for '{}' unknown, not configured as local user or local organization unknown", - certWrapper.thumbprint(), certWrapper.subjectDn()); + certWrapper.getThumbprint(), certWrapper.getSubjectDn()); return null; } }
dsf-common/dsf-common-auth/src/main/java/dev/dsf/common/auth/conf/AbstractIdentity.java+6 −0 modified@@ -85,6 +85,12 @@ public boolean equals(Object obj) return Objects.equals(organizationIdentifierValue, ((AbstractIdentity) obj).organizationIdentifierValue); } + @Override + public boolean isNotExpired() + { + return certificate != null && certificate.isNotExpired(); + } + @Override public boolean isLocalIdentity() {
dsf-common/dsf-common-auth/src/main/java/dev/dsf/common/auth/conf/AbstractIdentityProvider.java+2 −20 modified@@ -17,8 +17,6 @@ import java.net.URI; import java.net.URISyntaxException; -import java.security.cert.CertificateEncodingException; -import java.security.cert.X509Certificate; import java.util.Arrays; import java.util.List; import java.util.Map; @@ -260,26 +258,10 @@ protected final Optional<Practitioner> toPractitioner(X509CertificateWrapper cer if (certWrapper == null) return Optional.empty(); - if (!thumbprints.contains(certWrapper.thumbprint())) + if (!thumbprints.contains(certWrapper.getThumbprint())) return Optional.empty(); - return toJcaX509CertificateHolder(certWrapper.certificate()) - .flatMap(ch -> toPractitioner(ch, certWrapper.thumbprint())); - } - - private Optional<JcaX509CertificateHolder> toJcaX509CertificateHolder(X509Certificate certificate) - { - try - { - return Optional.of(new JcaX509CertificateHolder(certificate)); - } - catch (CertificateEncodingException e) - { - logger.debug("Unable to decode certificate", e); - logger.warn("Unable to decode certificate: {} - {}", e.getClass().getName(), e.getMessage()); - - return Optional.empty(); - } + return toPractitioner(certWrapper.toJcaX509CertificateHolder(), certWrapper.getThumbprint()); } private Optional<Practitioner> toPractitioner(JcaX509CertificateHolder certificate, String thumbprint)
dsf-common/dsf-common-auth/src/main/java/dev/dsf/common/auth/conf/Identity.java+9 −0 modified@@ -27,6 +27,11 @@ public interface Identity extends Principal String ORGANIZATION_IDENTIFIER_SYSTEM = "http://dsf.dev/sid/organization-identifier"; String ENDPOINT_IDENTIFIER_SYSTEM = "http://dsf.dev/sid/endpoint-identifier"; + /** + * @return <code>true</code> if credentials are not expired + */ + boolean isNotExpired(); + boolean isLocalIdentity(); /** @@ -38,6 +43,10 @@ public interface Identity extends Principal Set<DsfRole> getDsfRoles(); + /** + * @param role + * @return <code>true</code> if Identity has the given role + */ boolean hasDsfRole(DsfRole role); /**
dsf-common/dsf-common-auth/src/main/java/dev/dsf/common/auth/conf/PractitionerIdentityImpl.java+9 −0 modified@@ -100,6 +100,15 @@ public boolean equals(Object obj) && Objects.equals(practitionerIdentifierValue, other.practitionerIdentifierValue); } + @Override + public boolean isNotExpired() + { + if (credentials != null) + return credentials.isNotExpired(); + else + return super.isNotExpired(); + } + @Override public String getName() {
dsf-common/dsf-common-auth/src/main/java/dev/dsf/common/auth/conf/X509CertificateWrapper.java+48 −4 modified@@ -19,19 +19,31 @@ import java.security.NoSuchAlgorithmException; import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; +import java.time.Instant; import javax.security.auth.x500.X500Principal; import org.apache.commons.codec.binary.Hex; +import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder; -public record X509CertificateWrapper(X509Certificate certificate, String thumbprint, String subjectDn) +public class X509CertificateWrapper { + private final X509Certificate certificate; + private final String thumbprint; + private final String subjectDn; + + private final Instant expiration; + public X509CertificateWrapper(X509Certificate certificate) { - this(certificate, getThumbprint(certificate), getSubjectDn(certificate)); + this.certificate = certificate; + this.thumbprint = toThumbprint(certificate); + this.subjectDn = toSubjectDn(certificate); + + this.expiration = certificate == null ? null : certificate.getNotAfter().toInstant(); } - private static String getThumbprint(X509Certificate certificate) + private static String toThumbprint(X509Certificate certificate) { try { @@ -44,8 +56,40 @@ private static String getThumbprint(X509Certificate certificate) } } - private static String getSubjectDn(X509Certificate certificate) + private static String toSubjectDn(X509Certificate certificate) { return certificate.getSubjectX500Principal().getName(X500Principal.RFC1779); } + + public X509Certificate getCertificate() + { + return certificate; + } + + public String getThumbprint() + { + return thumbprint; + } + + public String getSubjectDn() + { + return subjectDn; + } + + public JcaX509CertificateHolder toJcaX509CertificateHolder() + { + try + { + return new JcaX509CertificateHolder(certificate); + } + catch (CertificateEncodingException e) + { + throw new RuntimeException(e); + } + } + + public boolean isNotExpired() + { + return expiration != null && Instant.now().isBefore(expiration); + } }
dsf-common/dsf-common-auth/src/main/java/dev/dsf/common/auth/DsfOpenIdCredentials.java+5 −0 modified@@ -42,4 +42,9 @@ public interface DsfOpenIdCredentials * @return <b>defaultValue</b> if no {@link String} entry with the given <b>key</b> in id-token */ String getStringClaimOrDefault(String key, String defaultValue); + + /** + * @return <code>true</code> if token not expired + */ + boolean isNotExpired(); }
dsf-common/dsf-common-auth/src/main/java/dev/dsf/common/auth/logging/CurrentUserMdcLogger.java+4 −4 modified@@ -50,9 +50,9 @@ protected void before(OrganizationIdentity organization) { before((Identity) organization); - organization.getCertificate().map(X509CertificateWrapper::thumbprint) + organization.getCertificate().map(X509CertificateWrapper::getThumbprint) .ifPresent(t -> MDC.put(DSF_ORGANIZATION_THUMBPRINT, t)); - organization.getCertificate().map(X509CertificateWrapper::subjectDn) + organization.getCertificate().map(X509CertificateWrapper::getSubjectDn) .ifPresent(d -> MDC.put(DSF_ORGANIZATION_DN, d)); MDC.put(DSF_ORGANIZATION_IDENTIFIER, organization.getOrganizationIdentifierValue()); @@ -64,9 +64,9 @@ protected void before(PractitionerIdentity practitioner) { before((Identity) practitioner); - practitioner.getCertificate().map(X509CertificateWrapper::thumbprint) + practitioner.getCertificate().map(X509CertificateWrapper::getThumbprint) .ifPresent(t -> MDC.put(DSF_PRACTITIONER_THUMBPRINT, t)); - practitioner.getCertificate().map(X509CertificateWrapper::subjectDn) + practitioner.getCertificate().map(X509CertificateWrapper::getSubjectDn) .ifPresent(d -> MDC.put(DSF_PRACTITIONER_DN, d)); practitioner.getCredentials().map(DsfOpenIdCredentials::getUserId) .ifPresent(i -> MDC.put(DSF_PRACTITIONER_SUB, i));
dsf-common/dsf-common-jetty/src/main/java/dev/dsf/common/auth/DsfOpenIdCredentialsImpl.java+21 −4 modified@@ -15,6 +15,7 @@ */ package dev.dsf.common.auth; +import java.time.Instant; import java.util.Collections; import java.util.Map; @@ -29,16 +30,26 @@ public class DsfOpenIdCredentialsImpl implements DsfOpenIdCredentials private final Map<String, Object> idToken; private final Map<String, Object> accessToken; + private final Instant expiration; + public DsfOpenIdCredentialsImpl(OpenIdCredentials credentials) { - this.idToken = JwtDecoder.decode((String) credentials.getResponse().get(ID_TOKEN)); - this.accessToken = JwtDecoder.decode((String) credentials.getResponse().get(ACCESS_TOKEN)); + this(JwtDecoder.decode((String) credentials.getResponse().get(ID_TOKEN)), + JwtDecoder.decode((String) credentials.getResponse().get(ACCESS_TOKEN))); } public DsfOpenIdCredentialsImpl(String accessToken) { - this.idToken = Map.of(); - this.accessToken = JwtDecoder.decode(accessToken); + this(Map.of(), JwtDecoder.decode(accessToken)); + } + + private DsfOpenIdCredentialsImpl(Map<String, Object> idToken, Map<String, Object> accessToken) + { + this.idToken = idToken; + this.accessToken = accessToken; + + Long exp = getLongClaim("exp"); + expiration = exp == null ? null : Instant.ofEpochSecond(exp); } @Override @@ -72,4 +83,10 @@ public String getStringClaimOrDefault(String key, String defaultValue) Object o = getAccessToken().getOrDefault(key, defaultValue); return o instanceof String s ? s : defaultValue; } + + @Override + public boolean isNotExpired() + { + return expiration != null && Instant.now().isBefore(expiration); + } }
dsf-common/dsf-common-jetty/src/main/java/dev/dsf/common/auth/DsfOpenIdLoginService.java+1 −3 modified@@ -63,9 +63,7 @@ public boolean validate(UserIdentity user) return false; } - long expiry = identity.getCredentials().get().getLongClaim("exp"); - long currentTimeSeconds = (long) (System.currentTimeMillis() / 1000F); - if (currentTimeSeconds > expiry) + if (!identity.isNotExpired()) { logger.debug("ID Token has expired"); return false;
dsf-common/dsf-common-jetty/src/main/java/dev/dsf/common/config/AbstractJettyConfig.java+3 −0 modified@@ -323,6 +323,8 @@ private void configureSecurityHandler(WebAppContext webAppContext, Supplier<Inte SessionHandler sessionHandler = webAppContext.getSessionHandler(); sessionHandler.setSameSite(SameSite.LAX); sessionHandler.setMaxInactiveInterval(oidcSessionTimeout()); + sessionHandler.setSessionIdPathParameterName(null); + sessionHandler.setRefreshCookieAge(Math.min(oidcSessionTimeout() / 2, 600)); SessionCookieConfig sessionCookieConfig = sessionHandler.getSessionCookieConfig(); sessionCookieConfig.setSecure(true); @@ -396,6 +398,7 @@ else if (oidcBackChannelPath == null) SecurityHandler securityHandler = new DsfSecurityHandler(dsfLoginService, delegatingAuthenticator, openIdConfiguration); securityHandler.setSessionRenewedOnAuthentication(true); + securityHandler.setSessionMaxInactiveIntervalOnAuthentication(oidcSessionTimeout()); webAppContext.setSecurityHandler(securityHandler);
dsf-common/dsf-common-jetty/src/main/java/dev/dsf/common/jetty/SessionInvalidator.java+0 −40 removed@@ -1,40 +0,0 @@ -/* - * Copyright 2018-2025 Heilbronn University of Applied Sciences - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package dev.dsf.common.jetty; - -import jakarta.servlet.ServletRequestEvent; -import jakarta.servlet.ServletRequestListener; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpSession; - -public class SessionInvalidator implements ServletRequestListener -{ - @Override - public void requestInitialized(ServletRequestEvent sre) - { - // nothing to do - } - - @Override - public void requestDestroyed(ServletRequestEvent sre) - { - HttpServletRequest servletRequest = (HttpServletRequest) sre.getServletRequest(); - HttpSession session = servletRequest.getSession(false); - - if (session != null) - session.invalidate(); - } -}
dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/authentication/IdentityProviderImpl.java+5 −5 modified@@ -82,15 +82,15 @@ public Identity getIdentity(X509Certificate[] certificates) X509CertificateWrapper certWrapper = new X509CertificateWrapper(certificates[0]); - Optional<Organization> organization = organizationProvider.getOrganization(certWrapper.thumbprint()); + Optional<Organization> organization = organizationProvider.getOrganization(certWrapper.getThumbprint()); if (organization.isPresent()) { Organization o = organization.get(); boolean local = isLocalOrganization(o); Optional<Endpoint> e = local ? getLocalEndpoint() - : endpointProvider.getEndpoint(o, certWrapper.thumbprint()); + : endpointProvider.getEndpoint(o, certWrapper.getThumbprint()); Set<FhirServerRole> r = local ? FhirServerRoleImpl.LOCAL_ORGANIZATION : FhirServerRoleImpl.REMOTE_ORGANIZATION; @@ -105,14 +105,14 @@ public Identity getIdentity(X509Certificate[] certificates) Organization o = localOrganization.get(); Endpoint e = getLocalEndpoint().orElse(null); - return new PractitionerIdentityImpl(o, e, getDsfRolesFor(p, certWrapper.thumbprint(), null, null), - certWrapper, p, getPractitionerRolesFor(p, certWrapper.thumbprint(), null, null), null); + return new PractitionerIdentityImpl(o, e, getDsfRolesFor(p, certWrapper.getThumbprint(), null, null), + certWrapper, p, getPractitionerRolesFor(p, certWrapper.getThumbprint(), null, null), null); } else { logger.warn( "Certificate with thumbprint '{}' for '{}' unknown, not part of allowlist and not configured as local user or local organization", - certWrapper.thumbprint(), certWrapper.subjectDn()); + certWrapper.getThumbprint(), certWrapper.getSubjectDn()); return null; } }
dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/authorization/AbstractAuthorizationRule.java+2 −2 modified@@ -324,7 +324,7 @@ protected final Optional<ResourceReference> createIfLiteralInternalOrLogicalRefe } @Override - public Optional<String> reasonPermanentDeleteAllowed(Identity identity, R oldResource) + public final Optional<String> reasonPermanentDeleteAllowed(Identity identity, R oldResource) { try (Connection connection = daoProvider.newReadOnlyAutoCommitTransaction()) { @@ -400,7 +400,7 @@ && reasonDeleteAllowed(connection, identity, oldResource).isPresent()) } @Override - public Optional<String> reasonWebsocketAllowed(Identity identity, R existingResource) + public final Optional<String> reasonWebsocketAllowed(Identity identity, R existingResource) { try (Connection connection = daoProvider.newReadOnlyAutoCommitTransaction()) {
dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/service/InitialDataLoaderImpl.java+8 −1 modified@@ -43,7 +43,14 @@ public class InitialDataLoaderImpl implements InitialDataLoader, InitializingBea org.addIdentifier().setSystem(ReadAccessHelper.ORGANIZATION_IDENTIFIER_SYSTEM).setValue("initial.data.loader"); INITIAL_DATA_LOADER = new OrganizationIdentityImpl(true, org, null, FhirServerRoleImpl.INITIAL_DATA_LOADER, - null); + null) + { + @Override + public boolean isNotExpired() + { + return true; + } + }; } private static final Logger logger = LoggerFactory.getLogger(InitialDataLoaderImpl.class);
dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/subscription/WebSocketSubscriptionManagerImpl.java+79 −35 modified@@ -19,10 +19,12 @@ import java.sql.SQLException; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; @@ -89,15 +91,29 @@ boolean matches(Resource resource, DaoProvider daoProvider) private static class SessionIdAndRemoteAsync { - final Identity identity; final String sessionId; + + final Identity identity; + final Session session; + final Async remoteAsync; - SessionIdAndRemoteAsync(Identity identity, String sessionId, Async remoteAsync) + SessionIdAndRemoteAsync(String sessionId) { - this.identity = identity; this.sessionId = sessionId; - this.remoteAsync = remoteAsync; + + identity = null; + session = null; + remoteAsync = null; + } + + SessionIdAndRemoteAsync(Identity identity, Session session) + { + this.sessionId = session.getId(); + + this.identity = identity; + this.session = session; + this.remoteAsync = session.getAsyncRemote(); } @Override @@ -116,6 +132,21 @@ public boolean equals(Object obj) SessionIdAndRemoteAsync other = (SessionIdAndRemoteAsync) obj; return Objects.equals(sessionId, other.sessionId); } + + void closeCredentialsExpired() + { + try + { + if (session != null) + session.close(new CloseReason(CloseCodes.VIOLATED_POLICY, "Credentials expired")); + } + catch (IOException e) + { + logger.warn("Error while closing websocket for user {}, session {}, {}", identity.getName(), + session.getId(), e.getMessage()); + logger.debug("Error while closing websocket", e); + } + } } private final ExecutorService executor = Executors.newCachedThreadPool(); @@ -130,7 +161,7 @@ public boolean equals(Object obj) private final AtomicBoolean firstCall = new AtomicBoolean(true); private final ReadWriteMap<String, Subscription> subscriptionsByIdPart = new ReadWriteMap<>(); private final ReadWriteMap<Class<? extends Resource>, List<SubscriptionAndMatcher>> matchersByResource = new ReadWriteMap<>(); - private final ReadWriteMap<String, List<SessionIdAndRemoteAsync>> asyncRemotesBySubscriptionIdPart = new ReadWriteMap<>(); + private final ReadWriteMap<String, Set<SessionIdAndRemoteAsync>> asyncRemotesBySubscriptionIdPart = new ReadWriteMap<>(); public WebSocketSubscriptionManagerImpl(DaoProvider daoProvider, ExceptionHandler exceptionHandler, MatcherFactory matcherFactory, FhirContext fhirContext, AuthorizationRuleProvider authorizationRuleProvider) @@ -271,7 +302,7 @@ private void doHandleEvent(Event event) private void doHandleEventWithSubscription(Subscription s, Event event) { - Optional<List<SessionIdAndRemoteAsync>> optRemotes = asyncRemotesBySubscriptionIdPart + Optional<Set<SessionIdAndRemoteAsync>> optRemotes = asyncRemotesBySubscriptionIdPart .get(s.getIdElement().getIdPart()); if (optRemotes.isEmpty()) @@ -315,37 +346,50 @@ private IParser configureParser(IParser p) private boolean userHasReadAndWebsocketAccess(SessionIdAndRemoteAsync sessionAndRemote, Event event) { - Optional<AuthorizationRule<?>> optRule = authorizationRuleProvider - .getAuthorizationRule(event.getResourceType()); - if (optRule.isPresent()) + if (sessionAndRemote.identity.isNotExpired()) { - @SuppressWarnings("unchecked") - AuthorizationRule<Resource> rule = (AuthorizationRule<Resource>) optRule.get(); - Optional<String> readAllowedReason = rule.reasonReadAllowed(sessionAndRemote.identity, event.getResource()); - Optional<String> websocketAllowedReason = rule.reasonWebsocketAllowed(sessionAndRemote.identity, - event.getResource()); - - if (readAllowedReason.isPresent() && websocketAllowedReason.isPresent()) + Optional<AuthorizationRule<?>> optRule = authorizationRuleProvider + .getAuthorizationRule(event.getResourceType()); + if (optRule.isPresent()) { - logger.info("Sending event {} to user {}, websocket access and read of {} allowed {}, {}", - event.getClass().getSimpleName(), sessionAndRemote.identity.getName(), - event.getResourceType().getSimpleName(), websocketAllowedReason.get(), - readAllowedReason.isPresent()); - return true; + @SuppressWarnings("unchecked") + AuthorizationRule<Resource> rule = (AuthorizationRule<Resource>) optRule.get(); + Optional<String> readAllowedReason = rule.reasonReadAllowed(sessionAndRemote.identity, + event.getResource()); + Optional<String> websocketAllowedReason = rule.reasonWebsocketAllowed(sessionAndRemote.identity, + event.getResource()); + + if (readAllowedReason.isPresent() && websocketAllowedReason.isPresent()) + { + logger.info("Sending event {} to user {}, websocket access and read of {} allowed {}, {}", + event.getClass().getSimpleName(), sessionAndRemote.identity.getName(), + event.getResourceType().getSimpleName(), websocketAllowedReason.get(), + readAllowedReason.isPresent()); + return true; + } + else + { + logger.warn("Skipping event {} for user {}, websocket access or read of {} not allowed", + event.getClass().getSimpleName(), sessionAndRemote.identity.getName(), + event.getResourceType().getSimpleName()); + return false; + } } else { - logger.warn("Skipping event {} for user {}, websocket access or read of {} not allowed", + logger.warn("Skipping event {} for user {}, no authorization rule for resource of type {} found", event.getClass().getSimpleName(), sessionAndRemote.identity.getName(), event.getResourceType().getSimpleName()); return false; } } else { - logger.warn("Skipping event {} for user {}, no authorization rule for resource of type {} found", - event.getClass().getSimpleName(), sessionAndRemote.identity.getName(), - event.getResourceType().getSimpleName()); + logger.warn("Closing session with id {} for user {}, credentials expired", sessionAndRemote.sessionId, + sessionAndRemote.identity.getName()); + + sessionAndRemote.closeCredentialsExpired(); + return false; } } @@ -373,18 +417,18 @@ public void bind(Identity identity, Session session, String subscriptionIdPart) if (subscriptionsByIdPart.containsKey(subscriptionIdPart)) { logger.debug("Binding websocket session {} to subscription {}", session.getId(), subscriptionIdPart); - asyncRemotesBySubscriptionIdPart.replace(subscriptionIdPart, list -> + asyncRemotesBySubscriptionIdPart.replace(subscriptionIdPart, set -> { - if (list == null) + if (set == null) { - List<SessionIdAndRemoteAsync> newList = new ArrayList<>(); - newList.add(new SessionIdAndRemoteAsync(identity, session.getId(), session.getAsyncRemote())); - return newList; + Set<SessionIdAndRemoteAsync> newSet = new HashSet<>(); + newSet.add(new SessionIdAndRemoteAsync(identity, session)); + return newSet; } else { - list.add(new SessionIdAndRemoteAsync(identity, session.getId(), session.getAsyncRemote())); - return list; + set.add(new SessionIdAndRemoteAsync(identity, session)); + return set; } }); session.getAsyncRemote().sendText("bound " + subscriptionIdPart); @@ -407,7 +451,7 @@ private void closeNotFound(Identity identity, Session session, String subscripti } catch (IOException e) { - logger.warn("Error while closing websocket with user {}, session {}, {}", identity.getName(), + logger.warn("Error while closing websocket for user {}, session {}, {}", identity.getName(), session.getId(), e.getMessage()); logger.debug("Error while closing websocket", e); } @@ -417,7 +461,7 @@ private void closeNotFound(Identity identity, Session session, String subscripti public void close(String sessionId) { logger.debug("Removing websocket session {}", sessionId); - asyncRemotesBySubscriptionIdPart.removeWhereValueMatches(List::isEmpty, - list -> list.remove(new SessionIdAndRemoteAsync(null, sessionId, null))); + asyncRemotesBySubscriptionIdPart.removeWhereValueMatches(Set::isEmpty, + s -> s.remove(new SessionIdAndRemoteAsync(sessionId))); } }
dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/websocket/ServerEndpoint.java+11 −0 modified@@ -27,6 +27,7 @@ import java.util.concurrent.TimeUnit; import org.apache.commons.codec.binary.Hex; +import org.eclipse.jetty.websocket.core.exception.CloseException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.DisposableBean; @@ -37,6 +38,7 @@ import dev.dsf.fhir.authentication.FhirServerRoleImpl; import dev.dsf.fhir.subscription.WebSocketSubscriptionManager; import jakarta.websocket.CloseReason; +import jakarta.websocket.CloseReason.CloseCode; import jakarta.websocket.CloseReason.CloseCodes; import jakarta.websocket.Endpoint; import jakarta.websocket.EndpointConfig; @@ -168,6 +170,15 @@ public void onError(Session session, Throwable throwable) { if (throwable == null) logger.info("Websocket closed with error, session {}: unknown error", session.getId()); + else if (throwable instanceof CloseException close) + { + String status = String.valueOf(close.getStatusCode()); + CloseCode c = CloseCodes.getCloseCode(close.getStatusCode()); + if (c instanceof CloseCodes cc) + status += " - " + cc.name(); + + logger.info("Websocket closed with status {}, session {}", status, session.getId()); + } else { logger.debug("Websocket closed with error, session {}", session.getId(), throwable);
dsf-fhir/dsf-fhir-server/src/test/java/dev/dsf/fhir/authentication/IdentityProviderTest.java+3 −3 modified@@ -218,7 +218,7 @@ public void testGetOrganizationIdentityByX509CertificateLocalOrganization() thro assertNotNull(orgI.getCertificate()); assertTrue(orgI.getCertificate().isPresent()); assertEquals(LOCAL_ORGANIZATION_CERTIFICATE, - orgI.getCertificate().map(X509CertificateWrapper::certificate).get()); + orgI.getCertificate().map(X509CertificateWrapper::getCertificate).get()); assertEquals(LOCAL_ORGANIZATION_IDENTIFIER_VALUE, orgI.getDisplayName()); assertEquals(FhirServerRoleImpl.LOCAL_ORGANIZATION, orgI.getDsfRoles()); assertEquals(LOCAL_ORGANIZATION_IDENTIFIER_VALUE, orgI.getName()); @@ -250,7 +250,7 @@ public void testGetOrganizationIdentityByX509CertificateRemoteOrganization() thr assertNotNull(orgI.getCertificate()); assertTrue(orgI.getCertificate().isPresent()); assertEquals(REMOTE_ORGANIZATION_CERTIFICATE, - orgI.getCertificate().map(X509CertificateWrapper::certificate).get()); + orgI.getCertificate().map(X509CertificateWrapper::getCertificate).get()); assertEquals(REMOTE_ORGANIZATION_IDENTIFIER_VALUE, orgI.getDisplayName()); assertEquals(FhirServerRoleImpl.REMOTE_ORGANIZATION, orgI.getDsfRoles()); assertEquals(REMOTE_ORGANIZATION_IDENTIFIER_VALUE, orgI.getName()); @@ -315,7 +315,7 @@ public void testGetPractitionerIdentityByX509Certificate() throws Exception assertNotNull(practitionerI.getCertificate()); assertTrue(practitionerI.getCertificate().isPresent()); assertEquals(LOCAL_PRACTITIONER_CERTIFICATE, - practitionerI.getCertificate().map(X509CertificateWrapper::certificate).get()); + practitionerI.getCertificate().map(X509CertificateWrapper::getCertificate).get()); assertNotNull(practitionerI.getCredentials()); assertTrue(practitionerI.getCredentials().isEmpty()); assertEquals(LOCAL_PRACTITIONER_NAME_GIVEN + " " + LOCAL_PRACTITIONER_NAME_FAMILY,
dsf-fhir/dsf-fhir-server/src/test/java/dev/dsf/fhir/authorization/process/TestOrganizationIdentity.java+6 −0 modified@@ -59,6 +59,12 @@ public String getDisplayName() throw new UnsupportedOperationException(); } + @Override + public boolean isNotExpired() + { + return false; + } + @Override public boolean isLocalIdentity() {
dsf-fhir/dsf-fhir-server/src/test/java/dev/dsf/fhir/authorization/process/TestPractitionerIdentity.java+6 −0 modified@@ -61,6 +61,12 @@ public String getDisplayName() throw new UnsupportedOperationException(); } + @Override + public boolean isNotExpired() + { + return false; + } + @Override public boolean isLocalIdentity() {
dsf-fhir/dsf-fhir-server/src/test/java/dev/dsf/fhir/dao/TestOrganizationIdentity.java+6 −0 modified@@ -30,6 +30,12 @@ private TestOrganizationIdentity(boolean localIdentity, Organization organizatio super(localIdentity, organization, null, roles, null); } + @Override + public boolean isNotExpired() + { + return true; + } + public static TestOrganizationIdentity local(Organization organization) { return new TestOrganizationIdentity(true, organization, FhirServerRoleImpl.LOCAL_ORGANIZATION);
f4ecb002f7d1improved session timeout config, new error handling code in UI
4 files changed · +322 −51
dsf-common/dsf-common-jetty/src/main/java/dev/dsf/common/config/AbstractJettyConfig.java+18 −7 modified@@ -212,6 +212,10 @@ public abstract class AbstractJettyConfig extends AbstractCertificateConfig @Value("${dev.dsf.server.auth.oidc.back.channel.logout.path:/back-channel-logout}") private String oidcBackChannelPath; + @Documentation(description = "Maximum inactivity period after which the server session for OIDC logins is invalidated; the access token may expire earlier, resulting in earlier session invalidation") + @Value("${dev.dsf.server.auth.oidc.session.timeout:PT30M}") + private String oidcSessionTimeout; + @Documentation(description = "Forward (http/https) proxy url, use *DEV_DSF_BPE_PROXY_NOPROXY* to list domains that do not require a forward proxy", example = "http://proxy.foo:8080") @Value("${dev.dsf.proxy.url:#{null}}") private String proxyUrl; @@ -318,6 +322,7 @@ private void configureSecurityHandler(WebAppContext webAppContext, Supplier<Inte { SessionHandler sessionHandler = webAppContext.getSessionHandler(); sessionHandler.setSameSite(SameSite.LAX); + sessionHandler.setMaxInactiveInterval(oidcSessionTimeout()); SessionCookieConfig sessionCookieConfig = sessionHandler.getSessionCookieConfig(); sessionCookieConfig.setSecure(true); @@ -334,7 +339,7 @@ private void configureSecurityHandler(WebAppContext webAppContext, Supplier<Inte if (oidcAuthorizationCodeFlowEnabled || oidcBearerTokenEnabled || oidcBackChannelLogoutEnabled) { openIdConfiguration = new OpenIdConfiguration.Builder(oidcProviderRealmBaseUrl, oidcClientId, - oidcClientSecret).httpClient(createOidcClient()).build(); + oidcClientSecret).logoutWhenIdTokenIsExpired(true).httpClient(createOidcClient()).build(); if (oidcAuthorizationCodeFlowEnabled) { @@ -441,20 +446,26 @@ private Duration getOidcProviderClientCacheJwksResourceTimeout() return assertPositive(Duration.parse(oidcProviderClientCacheJwksResourceTimeout)); } - @Bean - @Lazy - public Duration oidcProviderClientTimeoutRead() + private Duration oidcProviderClientTimeoutRead() { return assertPositive(Duration.parse(oidcProviderClientTimeoutRead)); } - @Bean - @Lazy - public Duration oidcProviderClientTimeoutConnect() + private Duration oidcProviderClientTimeoutConnect() { return assertPositive(Duration.parse(oidcProviderClientTimeoutConnect)); } + private int oidcSessionTimeout() + { + long seconds = assertPositive(Duration.parse(oidcSessionTimeout)).getSeconds(); + + if (seconds >= Integer.MAX_VALUE) + seconds = Integer.MAX_VALUE; + + return (int) seconds; + } + private Duration assertPositive(Duration duration) { if (duration != null && duration.isNegative())
dsf-fhir/dsf-fhir-server/src/main/resources/fhir/static/form.css+12 −2 modified@@ -255,15 +255,25 @@ input[type=number] { } button.submit { - background-color: #326F95; - color: #fff; + background-color: var(--color-prime); + color: var(--color-background); padding: 12px 60px; border: none; border-radius: 4px; cursor: pointer; float: left; } +@keyframes button-blink-red { + 0% { background-color: var(--color-info-red); color: var(--color-info-background-red); } + 33.3% { background-color: var(--color-prime); color: var(--color-background); } + 66.6% { background-color: var(--color-info-red); color: var(--color-info-background-red); } +} + +.button-blink { + animation: button-blink-red 0.7s steps(1); +} + .spinner-enabled { display: block; }
dsf-fhir/dsf-fhir-server/src/main/resources/fhir/static/form.js+275 −28 modified@@ -16,10 +16,8 @@ function startProcess() { const task = readTaskInputsFromForm() - if (task) { - const taskString = JSON.stringify(task) - createTask(taskString) - } + if (task) + createTask(task) } function readTaskInputsFromForm() { @@ -238,10 +236,8 @@ function newTaskInputQuantity(type, id, comparator, value, unit, system, code, o function completeQuestionnaireResponse() { const questionnaireResponse = readQuestionnaireResponseAnswersFromForm() - if (questionnaireResponse) { - const questionnaireResponseString = JSON.stringify(questionnaireResponse) - updateQuestionnaireResponse(questionnaireResponseString) - } + if (questionnaireResponse) + updateQuestionnaireResponse(questionnaireResponse) } function readQuestionnaireResponseAnswersFromForm() { @@ -659,36 +655,60 @@ function addError(errorListElement, message) { } function updateQuestionnaireResponse(questionnaireResponse) { + enableSpinner() + const fullUrl = window.location.origin + window.location.pathname const requestUrl = fullUrl.indexOf("/_history") < 0 ? fullUrl : fullUrl.slice(0, fullUrl.indexOf("/_history")) const resourceBaseUrlWithoutId = fullUrl.slice(0, fullUrl.indexOf("/QuestionnaireResponse") + "/QuestionnaireResponse".length) - - enableSpinner() + const questionnaireResponseString = JSON.stringify(questionnaireResponse) fetch(requestUrl, { method: "PUT", + redirect: "manual", headers: { "Content-type": "application/json", "Accept": "application/json" }, - body: questionnaireResponse - }).then(response => parseResponse(response, resourceBaseUrlWithoutId)) + body: questionnaireResponseString + }).then(response => { + if (response.type === "basic") + parseResponse(response, resourceBaseUrlWithoutId) + else if (response.type === "opaqueredirect") { + sessionStorage.setItem("QuestionnaireResponse.pending", questionnaireResponseString) + sessionStorage.setItem("QuestionnaireResponse.url", window.location.href) + + window.location.reload() + } else + console.warn("Unhandled response type", response.type) + }) } function createTask(task) { - const fullUrl = window.location.origin + window.location.pathname - const requestUrl = fullUrl.slice(0, fullUrl.indexOf("/Task") + "/Task".length) + enableSpinner() - enableSpinner() - - fetch(requestUrl, { - method: "POST", - headers: { - "Content-type": "application/json", - "Accept": "application/json" - }, - body: task - }).then(response => parseResponse(response, requestUrl)) + const fullUrl = window.location.origin + window.location.pathname + const requestUrl = fullUrl.slice(0, fullUrl.indexOf("/Task") + "/Task".length) + const taskString = JSON.stringify(task) + + fetch(requestUrl, { + method: "POST", + redirect: "manual", + headers: { + "Content-type": "application/json", + "Accept": "application/json" + }, + body: taskString + }).then(response => { + if (response.type === "basic") + parseResponse(response, requestUrl) + else if (response.type === "opaqueredirect") { + sessionStorage.setItem("Task.pending", taskString) + sessionStorage.setItem("Task.url", window.location.href) + + window.location.reload() + } else + console.warn("Unhandled response type", response.type) + }) } function parseResponse(response, resourceBaseUrlWithoutId) { @@ -750,13 +770,14 @@ function adaptQuestionnaireResponseInputsIfNotVersion1_0_0() { } } -function loadResource(url) { - return fetch(url, { +async function loadResource(url) { + const response = await fetch(url, { method: "GET", headers: { "Accept": "application/json" } - }).then(response => response.json()) + }) + return await response.json() } function parseStructureDefinition(bundle) { @@ -890,7 +911,7 @@ function appendInputRowAfter(id) { clone.querySelectorAll("[for]").forEach(e => e.setAttribute("for", id + "|" + index)) clone.querySelectorAll("input[id]").forEach(e => e.setAttribute("id", id + "|" + index)) - clone.querySelector("span[class='plus-minus-icon']").remove() + clone.querySelector("span[class='plus-minus-icon']")?.remove() clone.querySelectorAll("input").forEach(input => { input.value = '' @@ -938,4 +959,230 @@ function htmlToElement(html, innerText) { function getResourceAsJson() { const resource = document.getElementById("json").innerText return JSON.parse(resource) +} + +function normalizeUrl(url) { + return new URL(url).origin + new URL(url).pathname +} + +function getInputById(id) { + return document.querySelector(`input[id="${CSS.escape(id)}"]`) +} + +function toLocalDateTime(value) { + const date = new Date(value) + return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16) +} + +function handlePendingQuestionnaireResponse() { + const questionnaireResponseString = sessionStorage.getItem("QuestionnaireResponse.pending") + const url = sessionStorage.getItem("QuestionnaireResponse.url") + + sessionStorage.removeItem("QuestionnaireResponse.pending") + sessionStorage.removeItem("QuestionnaireResponse.url") + + if (!questionnaireResponseString || !url) + return + if (normalizeUrl(window.location.href) !== normalizeUrl(url)) + return + + const questionnaireResponse = JSON.parse(questionnaireResponseString) + questionnaireResponse.item.forEach(i => { + if (i.answer === undefined) + return; + + const values = { + boolean: i.answer[0]?.valueBoolean, + string: i.answer[0]?.valueString, + integer: i.answer[0]?.valueInteger, + decimal: i.answer[0]?.valueDecimal, + date: i.answer[0]?.valueDate, + time: i.answer[0]?.valueTime, + dateTime: i.answer[0]?.valueDateTime, + uri: i.answer[0]?.valueUri, + reference: i.answer[0]?.valueReference, + coding: i.answer[0]?.valueCoding, + quantity: i.answer[0]?.valueQuantity + } + + const primitiveValue = + values.boolean ?? values.string ?? values.integer ?? + values.decimal ?? values.date ?? values.time ?? + values.dateTime ?? values.uri + + if (primitiveValue != null) { + const input = getInputById(i.linkId + (values.boolean !== undefined ? `-${values.boolean}` : "")) + + if (input) { + if (values.boolean !== undefined) { + input.checked = true + } else if (values.dateTime) { + input.value = toLocalDateTime(values.dateTime) + } else { + input.value = primitiveValue + } + } + } else if (values.reference) { + const { reference, identifier } = values.reference + + if (reference) { + const input = getInputById(i.linkId) + if (input) input.value = reference + + } else if (identifier) { + const { system, value } = identifier + + const inputSystem = getInputById(i.linkId + "-system") + const inputValue = getInputById(i.linkId + "-value") + + if (inputSystem) inputSystem.value = system + if (inputValue) inputValue.value = value + } + } else if (values.coding) { + const { system, code } = values.coding + + const inputSystem = getInputById(i.linkId + "-system") + const inputCode = getInputById(i.linkId + "-code") + + if (inputSystem) inputSystem.value = system + if (inputCode) inputCode.value = code + } else if (values.quantity) { + const { comparator, value, unit, system, code } = values.quantity + + const fields = { comparator, value, unit, system, code } + + Object.entries(fields).forEach(([key, val]) => { + if (val == null) return + const input = getInputById(`${i.linkId}-${key}`) + if (input) input.value = val + }) + } + }) + + blinkButton("complete-questionnaire-response") +} + +function handlePendingTask() { + const taskString = sessionStorage.getItem("Task.pending") + const url = sessionStorage.getItem("Task.url") + + sessionStorage.removeItem("Task.pending") + sessionStorage.removeItem("Task.url") + + if (!taskString || !url) + return + if (normalizeUrl(window.location.href) !== normalizeUrl(url)) + return + + const baseIdCount = new Map() + + const task = JSON.parse(taskString) + task.input.forEach(i => { + const values = { + boolean: i.valueBoolean, + string: i.valueString, + integer: i.valueInteger, + decimal: i.valueDecimal, + date: i.valueDate, + time: i.valueTime, + dateTime: i.valueDateTime, + instant: i.valueInstant, + uri: i.valueUri, + reference: i.valueReference, + identifier: i.valueIdentifier, + coding: i.valueCoding, + quantity: i.valueQuantity + } + + i.type.coding.forEach(c => { + const baseId = `${c.system}|${c.code}` + + const count = baseIdCount.get(baseId) || 0 + baseIdCount.set(baseId, count + 1) + + const suffix = count === 0 ? "" : `|${count}` + + if (count > 0) + appendInputRowAfter(baseId) + + const fullId = (id) => id + suffix + + const primitiveValue = + values.boolean ?? values.string ?? values.integer ?? + values.decimal ?? values.date ?? values.time ?? + values.dateTime ?? values.instant ?? values.uri + + if (primitiveValue != null) { + const id = fullId(baseId + (values.boolean !== undefined ? `-${values.boolean}` : "")) + + const input = getInputById(id) + + if (input) { + if (values.boolean !== undefined) { + input.checked = true + } else if (values.dateTime || values.instant) { + input.value = toLocalDateTime(values.dateTime || values.instant) + } else { + input.value = primitiveValue + } + } + } else if (values.reference) { + const { reference, identifier } = values.reference + + if (reference) { + const input = getInputById(fullId(baseId)) + if (input) input.value = reference + + } else if (identifier) { + const { system, value } = identifier + + const inputSystem = getInputById(fullId(baseId + "-system")) + const inputValue = getInputById(fullId(baseId + "-value")) + + if (inputSystem) inputSystem.value = system + if (inputValue) inputValue.value = value + } + } else if (values.identifier) { + const { system, value } = values.identifier + + const inputSystem = getInputById(fullId(baseId + "-system")) + const inputValue = getInputById(fullId(baseId + "-value")) + + if (inputSystem) inputSystem.value = system + if (inputValue) inputValue.value = value + } else if (values.coding) { + const { system, code } = values.coding + + const inputSystem = getInputById(fullId(baseId + "-system")) + const inputCode = getInputById(fullId(baseId + "-code")) + + if (inputSystem) inputSystem.value = system + if (inputCode) inputCode.value = code + } else if (values.quantity) { + const { comparator, value, unit, system, code } = values.quantity + + const fields = { comparator, value, unit, system, code } + + Object.entries(fields).forEach(([key, val]) => { + if (val == null) return + const input = getInputById(fullId(`${baseId}-${key}`)) + if (input) input.value = val + }) + } + }) + }) + + blinkButton("start-process") +} + +function blinkButton(id) { + window.addEventListener("load", function() { + const button = document.getElementById(id); + + if (button) { + button.scrollIntoView({behavior: "instant", block: "center"}) + button.classList.add("button-blink") + setTimeout(() => button.classList.remove("button-blink") , 2000) + } + }); } \ No newline at end of file
dsf-fhir/dsf-fhir-server/src/main/resources/fhir/static/main.js+17 −14 modified@@ -107,6 +107,9 @@ window.addEventListener('DOMContentLoaded', () => { completeQuestionnaireResponse() event.preventDefault() }) + + // pending QuestionnaireResponse + handlePendingQuestionnaireResponse() } if (resourceType != null && resourceType[1] === 'Task' && resourceType[2] && (resourceType[3] === undefined || resourceType[4])) { @@ -145,31 +148,31 @@ window.addEventListener('DOMContentLoaded', () => { startProcess() event.preventDefault() }) + + // pending Task + handlePendingTask() } document.querySelectorAll(".collapse-button").forEach(button => { button.addEventListener("click", () => { button.classList.toggle("collapse-button-rotated") - const parent = button.closest(".collapsable"); - parent.classList.toggle("collapsed"); - parent.classList.toggle("expanded"); + const parent = button.closest(".collapsable") + parent.classList.toggle("collapsed") + parent.classList.toggle("expanded") }) - }); + }) document.querySelectorAll(".collapsable").forEach(element => { - content = element.querySelector(".content-pre"); + const content = element.querySelector(".content-pre") + if (!content) + return - function checkOverflow() { - if (content.scrollHeight > element.clientHeight) { - element.classList.add("overflow"); - } else { - element.classList.add("no-overflow"); - } - } + const hasOverflow = content.scrollHeight > element.clientHeight - checkOverflow(); - }); + element.classList.toggle("overflow", hasOverflow) + element.classList.toggle("no-overflow", !hasOverflow) + }) }) window.addEventListener("popstate", (event) => {
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-gj7p-595x-qwf5ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-40939ghsaADVISORY
- dsf.dev/operations/v2.1.0/bpe/oidc.htmlnvdWEB
- dsf.dev/operations/v2.1.0/fhir/oidc.htmlnvdWEB
- github.com/datasharingframework/dsf/commit/7d25feafb83d66cb59985ac88568b67d937b1937ghsaWEB
- github.com/datasharingframework/dsf/commit/f4ecb002f7d12642f92da6b79371ed367d0140e7nvdWEB
- github.com/datasharingframework/dsf/security/advisories/GHSA-gj7p-595x-qwf5nvdWEB
News mentions
0No linked articles in our index yet.