diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 1e8d79d5e9..4f676c1688 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -133,7 +133,6 @@ jobs:
build-args: |
GIT_BRANCH=${{ steps.docker_meta.outputs.version }}
GIT_COMMIT_ID_ABBREV=${{ steps.build_params.outputs.sha8 }}
- MAVEN_PROFILE=webapi-docker,tcache
tags: ${{ steps.docker_meta.outputs.tags }}
# Use runtime labels from docker_meta as well as fixed labels
labels: |
diff --git a/Dockerfile b/Dockerfile
index e3d98cc282..b1ebb7211d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,7 +2,7 @@ FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /code
-ARG MAVEN_PROFILE=webapi-docker,tcache
+ARG MAVEN_PROFILE=webapi-docker,trexsql
ARG MAVEN_PARAMS="" # can use maven options, e.g. -DskipTests=true -DskipUnitTests=true
ARG OPENTELEMETRY_JAVA_AGENT_VERSION=1.17.0
diff --git a/pom.xml b/pom.xml
index 89e52605ec..33d89ebd96 100644
--- a/pom.xml
+++ b/pom.xml
@@ -497,7 +497,7 @@
-parameters
-
+
**/trexsql/**
@@ -1051,6 +1051,12 @@
+
+
+ com.nimbusds
+ oauth2-oidc-sdk
+ 11.20.1
+
org.pac4j
pac4j-http
@@ -1254,7 +1260,7 @@
- tcache
+ trexsql
true
@@ -1262,7 +1268,7 @@
com.github.p-hoffmann
trexsql-ext
- v0.1.18
+ v0.1.23
@@ -1271,7 +1277,7 @@
org.apache.maven.plugins
maven-compiler-plugin
-
+
diff --git a/src/main/java/io/buji/pac4j/filter/CallbackFilter.java b/src/main/java/io/buji/pac4j/filter/CallbackFilter.java
index 0bfde3fb2f..5425fa6fbb 100644
--- a/src/main/java/io/buji/pac4j/filter/CallbackFilter.java
+++ b/src/main/java/io/buji/pac4j/filter/CallbackFilter.java
@@ -6,6 +6,7 @@
import org.pac4j.core.config.Config;
import org.pac4j.core.engine.CallbackLogic;
import org.pac4j.core.engine.DefaultCallbackLogic;
+import org.pac4j.core.engine.savedrequest.SavedRequestHandler;
import org.pac4j.jee.context.JEEFrameworkParameters;
import java.io.IOException;
@@ -18,28 +19,38 @@ public class CallbackFilter implements Filter {
private String defaultUrl = "/";
private Config config;
private CallbackLogic callbackLogic;
-
+
public CallbackFilter() {
this.callbackLogic = new DefaultCallbackLogic();
}
-
+
public void setDefaultUrl(String url) {
this.defaultUrl = url;
}
-
+
public void setConfig(Config config) {
this.config = config;
}
-
+
+ /**
+ * Set a custom SavedRequestHandler on the callback logic.
+ * This allows customizing where users are redirected after authentication.
+ */
+ public void setSavedRequestHandler(SavedRequestHandler savedRequestHandler) {
+ if (this.callbackLogic instanceof DefaultCallbackLogic) {
+ ((DefaultCallbackLogic) this.callbackLogic).setSavedRequestHandler(savedRequestHandler);
+ }
+ }
+
@Override
- public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
-
+
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
-
+
JEEFrameworkParameters parameters = new JEEFrameworkParameters(httpRequest, httpResponse);
-
+
// Execute pac4j 6.x callback logic
callbackLogic.perform(
config,
@@ -48,7 +59,6 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha
null, // defaultClient
parameters
);
-
// Callback logic handles the response, don't continue chain
}
}
diff --git a/src/main/java/org/ohdsi/webapi/JerseyConfig.java b/src/main/java/org/ohdsi/webapi/JerseyConfig.java
index a290cbd5c6..9f9089f636 100644
--- a/src/main/java/org/ohdsi/webapi/JerseyConfig.java
+++ b/src/main/java/org/ohdsi/webapi/JerseyConfig.java
@@ -6,6 +6,7 @@
import org.glassfish.jersey.server.ResourceConfig;
import org.glassfish.jersey.server.filter.EncodingFilter;
import org.glassfish.jersey.server.spi.internal.ValueParamProvider;
+import org.ohdsi.webapi.auth.AuthProviderService;
import org.ohdsi.webapi.info.InfoService;
import org.ohdsi.webapi.security.PermissionController;
import org.ohdsi.webapi.security.SSOController;
@@ -44,6 +45,7 @@ public JerseyConfig(@Value("${jersey.resources.root.package}") String rootPackag
// Register individual services
register(ActivityService.class);
+ register(AuthProviderService.class);
register(CacheService.class);
register(CDMResultsService.class);
register(CohortAnalysisService.class);
diff --git a/src/main/java/org/ohdsi/webapi/OidcConfCreator.java b/src/main/java/org/ohdsi/webapi/OidcConfCreator.java
index 16003b204a..92aa5699db 100644
--- a/src/main/java/org/ohdsi/webapi/OidcConfCreator.java
+++ b/src/main/java/org/ohdsi/webapi/OidcConfCreator.java
@@ -19,7 +19,10 @@
package org.ohdsi.webapi;
import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.oauth2.sdk.pkce.CodeChallengeMethod;
import org.pac4j.oidc.config.OidcConfiguration;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@@ -30,6 +33,11 @@
@Component
public class OidcConfCreator {
+ private static final Logger logger = LoggerFactory.getLogger(OidcConfCreator.class);
+
+ private volatile OidcConfiguration cachedConfiguration;
+ private final Object lock = new Object();
+
@Value("${security.oid.clientId}")
private String clientId;
@@ -38,7 +46,10 @@ public class OidcConfCreator {
@Value("${security.oid.url}")
private String url;
-
+
+ @Value("${security.oid.externalUrl:}")
+ private String externalUrl;
+
@Value("${security.oid.logoutUrl}")
private String logoutUrl;
@@ -47,31 +58,74 @@ public class OidcConfCreator {
@Value("#{${security.oid.customParams:{T(java.util.Collections).emptyMap()}}}")
private Map customParams = new HashMap<>();
-
+
@Value("${security.oauth.callback.api}")
private String oauthApiCallback;
+ /**
+ * Returns the external OIDC URL for browser-facing endpoints.
+ * If externalUrl is set, returns it; otherwise returns the discovery URL.
+ */
+ public String getExternalUrl() {
+ if (externalUrl != null && !externalUrl.isEmpty()) {
+ return externalUrl;
+ }
+ // Fall back to discovery URL, removing the .well-known path if present
+ if (url != null && url.contains("/.well-known/")) {
+ return url.substring(0, url.indexOf("/.well-known/"));
+ }
+ return url;
+ }
+
public OidcConfiguration build() {
- OidcConfiguration conf = new OidcConfiguration();
- conf.setClientId(clientId);
- conf.setSecret(apiSecret);
- conf.setDiscoveryURI(url);
- conf.setLogoutUrl(logoutUrl);
- conf.setWithState(true);
- conf.setUseNonce(true);
-
- if (customParams != null) {
- customParams.forEach(conf::addCustomParam);
+ OidcConfiguration cached = cachedConfiguration;
+ if (cached != null) {
+ return cached;
}
- String scopes = "openid";
- if (extraScopes != null && !extraScopes.isEmpty()){
- scopes += " ";
- scopes += extraScopes;
+ synchronized (lock) {
+ cached = cachedConfiguration;
+ if (cached != null) {
+ return cached;
+ }
+
+ OidcConfiguration conf = new OidcConfiguration();
+ conf.setClientId(clientId);
+ conf.setSecret(apiSecret);
+ conf.setDiscoveryURI(url);
+ conf.setLogoutUrl(logoutUrl);
+ conf.setWithState(true);
+ conf.setUseNonce(true);
+
+ if (customParams != null) {
+ customParams.forEach(conf::addCustomParam);
+ }
+
+ String scopes = "openid";
+ if (extraScopes != null && !extraScopes.isEmpty()) {
+ scopes += " ";
+ scopes += extraScopes;
+ }
+ conf.setScope(scopes);
+ conf.setPreferredJwsAlgorithm(JWSAlgorithm.RS256);
+ conf.setPkceMethod(CodeChallengeMethod.S256);
+
+ try {
+ logger.info("Initializing OIDC configuration with discovery URL: {}", url);
+ conf.init();
+
+ var resolver = conf.getOpMetadataResolver();
+ if (resolver != null && resolver.load() != null) {
+ cachedConfiguration = conf;
+ } else {
+ logger.error("OIDC metadata resolver returned null");
+ }
+ } catch (Exception e) {
+ logger.error("Failed to initialize OIDC configuration", e);
+ }
+
+ return conf;
}
- conf.setScope(scopes);
- conf.setPreferredJwsAlgorithm(JWSAlgorithm.RS256);
- return conf;
}
}
diff --git a/src/main/java/org/ohdsi/webapi/auth/AuthProviderInfo.java b/src/main/java/org/ohdsi/webapi/auth/AuthProviderInfo.java
new file mode 100644
index 0000000000..6cd3c15aa7
--- /dev/null
+++ b/src/main/java/org/ohdsi/webapi/auth/AuthProviderInfo.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright 2024 Observational Health Data Sciences and Informatics [OHDSI.org].
+ *
+ * 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 org.ohdsi.webapi.auth;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+/**
+ * DTO representing an authentication provider configuration for Atlas frontend.
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class AuthProviderInfo {
+
+ private String name;
+ private String url;
+ private boolean ajax;
+ private String icon;
+ private boolean isUseCredentialsForm;
+ private String logoutUrl;
+
+ public AuthProviderInfo() {
+ }
+
+ public AuthProviderInfo(String name, String url, boolean ajax, String icon, boolean isUseCredentialsForm) {
+ this.name = name;
+ this.url = url;
+ this.ajax = ajax;
+ this.icon = icon;
+ this.isUseCredentialsForm = isUseCredentialsForm;
+ }
+
+ public AuthProviderInfo(String name, String url, boolean ajax, String icon, boolean isUseCredentialsForm, String logoutUrl) {
+ this(name, url, ajax, icon, isUseCredentialsForm);
+ this.logoutUrl = logoutUrl;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getUrl() {
+ return url;
+ }
+
+ public void setUrl(String url) {
+ this.url = url;
+ }
+
+ public boolean isAjax() {
+ return ajax;
+ }
+
+ public void setAjax(boolean ajax) {
+ this.ajax = ajax;
+ }
+
+ public String getIcon() {
+ return icon;
+ }
+
+ public void setIcon(String icon) {
+ this.icon = icon;
+ }
+
+ public boolean isUseCredentialsForm() {
+ return isUseCredentialsForm;
+ }
+
+ public void setUseCredentialsForm(boolean isUseCredentialsForm) {
+ this.isUseCredentialsForm = isUseCredentialsForm;
+ }
+
+ public String getLogoutUrl() {
+ return logoutUrl;
+ }
+
+ public void setLogoutUrl(String logoutUrl) {
+ this.logoutUrl = logoutUrl;
+ }
+}
diff --git a/src/main/java/org/ohdsi/webapi/auth/AuthProviderService.java b/src/main/java/org/ohdsi/webapi/auth/AuthProviderService.java
new file mode 100644
index 0000000000..d82ce0c889
--- /dev/null
+++ b/src/main/java/org/ohdsi/webapi/auth/AuthProviderService.java
@@ -0,0 +1,207 @@
+/*
+ * Copyright 2024 Observational Health Data Sciences and Informatics [OHDSI.org].
+ *
+ * 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 org.ohdsi.webapi.auth;
+
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Controller;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Service that exposes available authentication providers for Atlas frontend.
+ */
+@Path("/auth")
+@Controller
+public class AuthProviderService {
+
+ @Value("${security.auth.jdbc.enabled}")
+ private boolean jdbcAuthEnabled;
+
+ @Value("${security.auth.windows.enabled}")
+ private boolean windowsAuthEnabled;
+
+ @Value("${security.auth.kerberos.enabled}")
+ private boolean kerberosAuthEnabled;
+
+ @Value("${security.auth.ldap.enabled}")
+ private boolean ldapAuthEnabled;
+
+ @Value("${security.auth.ad.enabled}")
+ private boolean adAuthEnabled;
+
+ @Value("${security.auth.cas.enabled}")
+ private boolean casAuthEnabled;
+
+ @Value("${security.auth.openid.enabled}")
+ private boolean openidAuthEnabled;
+
+ @Value("${security.auth.facebook.enabled}")
+ private boolean facebookAuthEnabled;
+
+ @Value("${security.auth.github.enabled}")
+ private boolean githubAuthEnabled;
+
+ @Value("${security.auth.google.enabled}")
+ private boolean googleAuthEnabled;
+
+ @Value("${security.auth.saml.enabled:false}")
+ private boolean samlAuthEnabled;
+
+ @Value("${security.oid.logoutUrl:}")
+ private String oidcLogoutUrl;
+
+ /**
+ * Get the list of enabled authentication providers.
+ * This endpoint is publicly accessible (no auth required).
+ */
+ @GET
+ @Path("/providers")
+ @Produces(MediaType.APPLICATION_JSON)
+ public List getProviders() {
+ List providers = new ArrayList<>();
+
+ // OpenID Connect (e.g., Logto, Keycloak, Okta)
+ if (openidAuthEnabled) {
+ providers.add(new AuthProviderInfo(
+ "OpenID",
+ "user/login/openid",
+ false,
+ "mdi-shield-account",
+ false,
+ oidcLogoutUrl != null && !oidcLogoutUrl.isEmpty() ? oidcLogoutUrl : null
+ ));
+ }
+
+ // Database authentication (JDBC)
+ if (jdbcAuthEnabled) {
+ providers.add(new AuthProviderInfo(
+ "DB",
+ "user/login/db",
+ true,
+ "mdi-database",
+ true
+ ));
+ }
+
+ // Windows authentication
+ if (windowsAuthEnabled) {
+ providers.add(new AuthProviderInfo(
+ "Windows",
+ "user/login/windows",
+ true,
+ "mdi-microsoft-windows",
+ false
+ ));
+ }
+
+ // Kerberos authentication
+ if (kerberosAuthEnabled) {
+ providers.add(new AuthProviderInfo(
+ "Kerberos",
+ "user/login/kerberos",
+ true,
+ "mdi-shield-key",
+ true
+ ));
+ }
+
+ // LDAP authentication
+ if (ldapAuthEnabled) {
+ providers.add(new AuthProviderInfo(
+ "LDAP",
+ "user/login/ldap",
+ true,
+ "mdi-folder-network",
+ true
+ ));
+ }
+
+ // Active Directory authentication
+ if (adAuthEnabled) {
+ providers.add(new AuthProviderInfo(
+ "Active Directory",
+ "user/login/ad",
+ true,
+ "mdi-microsoft",
+ true
+ ));
+ }
+
+ // CAS authentication
+ if (casAuthEnabled) {
+ providers.add(new AuthProviderInfo(
+ "CAS",
+ "user/login/cas",
+ false,
+ "mdi-account-key",
+ false
+ ));
+ }
+
+ // SAML authentication
+ if (samlAuthEnabled) {
+ providers.add(new AuthProviderInfo(
+ "SAML",
+ "user/login/saml",
+ false,
+ "mdi-security",
+ false
+ ));
+ }
+
+ // Google OAuth
+ if (googleAuthEnabled) {
+ providers.add(new AuthProviderInfo(
+ "Google",
+ "user/login/google",
+ false,
+ "mdi-google",
+ false
+ ));
+ }
+
+ // Facebook OAuth
+ if (facebookAuthEnabled) {
+ providers.add(new AuthProviderInfo(
+ "Facebook",
+ "user/login/facebook",
+ false,
+ "mdi-facebook",
+ false
+ ));
+ }
+
+ // GitHub OAuth
+ if (githubAuthEnabled) {
+ providers.add(new AuthProviderInfo(
+ "GitHub",
+ "user/login/github",
+ false,
+ "mdi-github",
+ false
+ ));
+ }
+
+ return providers;
+ }
+}
diff --git a/src/main/java/org/ohdsi/webapi/job/JobUtils.java b/src/main/java/org/ohdsi/webapi/job/JobUtils.java
index 7333593cc6..b33b6ec9a1 100644
--- a/src/main/java/org/ohdsi/webapi/job/JobUtils.java
+++ b/src/main/java/org/ohdsi/webapi/job/JobUtils.java
@@ -95,26 +95,43 @@ public static List toJobExecutionResource(final ResultSet
jobexec = toJobExecutionResource(jobExecution);
}
- //parameters starts at 12
+ // Parameters start at column 12
String key = rs.getString(12);
- if (!PROTECTED_PARAMS.contains(key)) {
+ if (key != null && !PROTECTED_PARAMS.contains(key)) {
String typeStr = rs.getString(13);
- JobParameter> value = null;
-
- // Spring Batch 5: Simplified parameter creation
- if ("STRING".equals(typeStr)) {
- value = new JobParameter<>(rs.getString(14), String.class);
- } else if ("LONG".equals(typeStr)) {
- value = new JobParameter<>(rs.getLong(16), Long.class);
- } else if ("DOUBLE".equals(typeStr)) {
- value = new JobParameter<>(rs.getDouble(17), Double.class);
- } else if ("DATE".equals(typeStr)) {
- value = new JobParameter<>(rs.getTimestamp(15), java.util.Date.class);
+ String valueStr = rs.getString(14);
+ Object value = null;
+
+ if (valueStr != null) {
+ if ("java.lang.String".equals(typeStr) || "STRING".equals(typeStr)) {
+ value = valueStr;
+ } else if ("java.lang.Long".equals(typeStr) || "LONG".equals(typeStr)) {
+ try {
+ value = Long.parseLong(valueStr);
+ } catch (NumberFormatException e) {
+ value = valueStr;
+ }
+ } else if ("java.lang.Double".equals(typeStr) || "DOUBLE".equals(typeStr)) {
+ try {
+ value = Double.parseDouble(valueStr);
+ } catch (NumberFormatException e) {
+ value = valueStr;
+ }
+ } else if ("java.util.Date".equals(typeStr) || "DATE".equals(typeStr)) {
+ try {
+ value = Long.parseLong(valueStr);
+ } catch (NumberFormatException e) {
+ value = valueStr;
+ }
+ } else {
+ value = valueStr;
+ }
}
- // No need to assert that value is not null because it's an enum
- map.put(key, value.getValue());//value);
+ if (value != null) {
+ map.put(key, value);
+ }
}
}
diff --git a/src/main/java/org/ohdsi/webapi/shiro/filters/AtlasCallbackFilter.java b/src/main/java/org/ohdsi/webapi/shiro/filters/AtlasCallbackFilter.java
new file mode 100644
index 0000000000..5dd4258390
--- /dev/null
+++ b/src/main/java/org/ohdsi/webapi/shiro/filters/AtlasCallbackFilter.java
@@ -0,0 +1,142 @@
+package org.ohdsi.webapi.shiro.filters;
+
+import io.buji.pac4j.filter.CallbackFilter;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpServletResponseWrapper;
+import org.ohdsi.webapi.shiro.ServletBridge;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+
+/**
+ * Custom callback filter that intercepts redirects and ensures
+ * successful authentication always redirects to the Atlas UI,
+ * not to the originally saved request URL.
+ */
+public class AtlasCallbackFilter extends CallbackFilter {
+
+ private static final Logger logger = LoggerFactory.getLogger(AtlasCallbackFilter.class);
+ private String atlasRedirectUrl;
+
+ public void setAtlasRedirectUrl(String atlasRedirectUrl) {
+ this.atlasRedirectUrl = atlasRedirectUrl;
+ }
+
+ @Override
+ public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
+ throws IOException, ServletException {
+
+ HttpServletRequest request = ServletBridge.toHttp(servletRequest);
+ HttpServletResponse response = ServletBridge.toHttp(servletResponse);
+
+ // Use WARN level to ensure it appears in logs
+ logger.warn("AtlasCallbackFilter.doFilter ENTERED for URI: {}", request.getRequestURI());
+ System.out.println("AtlasCallbackFilter.doFilter ENTERED for URI: " + request.getRequestURI());
+
+ // Wrap the response to intercept redirects (via sendRedirect OR setHeader/setStatus)
+ RedirectCapturingResponseWrapper responseWrapper = new RedirectCapturingResponseWrapper(response);
+
+ // Execute the parent callback filter with the wrapped response
+ super.doFilter(request, responseWrapper, filterChain);
+
+ logger.warn("AtlasCallbackFilter: After parent filter - redirectLocation='{}', status={}, isRedirect={}",
+ responseWrapper.getRedirectLocation(), responseWrapper.getCapturedStatus(), responseWrapper.isRedirect());
+ System.out.println("AtlasCallbackFilter: After parent filter - redirectLocation=" + responseWrapper.getRedirectLocation());
+
+ // If a redirect was captured (either via sendRedirect or setHeader/setStatus), override it
+ if (responseWrapper.getRedirectLocation() != null) {
+ String capturedRedirect = responseWrapper.getRedirectLocation();
+ logger.warn("AtlasCallbackFilter: Intercepted redirect to '{}', atlasRedirectUrl='{}'",
+ capturedRedirect, atlasRedirectUrl);
+
+ // Only override if it's not already pointing to Atlas and we have a configured URL
+ if (atlasRedirectUrl != null && !capturedRedirect.contains("/atlas/")) {
+ logger.warn("AtlasCallbackFilter: Overriding redirect to Atlas UI: {}", atlasRedirectUrl);
+ response.sendRedirect(atlasRedirectUrl);
+ } else {
+ // Use the original redirect
+ logger.warn("AtlasCallbackFilter: Using original redirect: {}", capturedRedirect);
+ response.sendRedirect(capturedRedirect);
+ }
+ } else {
+ logger.warn("AtlasCallbackFilter: No redirect captured, response committed={}", response.isCommitted());
+ System.out.println("AtlasCallbackFilter: No redirect captured, response committed=" + response.isCommitted());
+ }
+ }
+
+ /**
+ * Wrapper that captures redirect calls instead of executing them.
+ * Intercepts both sendRedirect() and setHeader("Location", ...) + setStatus(302)
+ * since pac4j uses the latter approach via JEEHttpActionAdapter.
+ */
+ private static class RedirectCapturingResponseWrapper extends HttpServletResponseWrapper {
+ private String redirectLocation;
+ private int statusCode = 200;
+ private final HttpServletResponse originalResponse;
+
+ public RedirectCapturingResponseWrapper(HttpServletResponse response) {
+ super(response);
+ this.originalResponse = response;
+ }
+
+ @Override
+ public void sendRedirect(String location) throws IOException {
+ // Don't actually redirect, just capture the location
+ logger.warn("RedirectCapturingResponseWrapper: sendRedirect called with '{}'", location);
+ System.out.println("RedirectCapturingResponseWrapper: sendRedirect called with '" + location + "'");
+ this.redirectLocation = location;
+ this.statusCode = 302;
+ }
+
+ @Override
+ public void setStatus(int sc) {
+ logger.warn("RedirectCapturingResponseWrapper: setStatus called with {}", sc);
+ System.out.println("RedirectCapturingResponseWrapper: setStatus called with " + sc);
+ this.statusCode = sc;
+ // Don't pass through redirect status codes
+ if (sc != 302 && sc != 301 && sc != 303 && sc != 307 && sc != 308) {
+ super.setStatus(sc);
+ }
+ }
+
+ @Override
+ public void setHeader(String name, String value) {
+ logger.warn("RedirectCapturingResponseWrapper: setHeader called with '{}' = '{}'", name, value);
+ System.out.println("RedirectCapturingResponseWrapper: setHeader called with '" + name + "' = '" + value + "'");
+ if ("Location".equalsIgnoreCase(name)) {
+ this.redirectLocation = value;
+ } else {
+ super.setHeader(name, value);
+ }
+ }
+
+ @Override
+ public void addHeader(String name, String value) {
+ logger.warn("RedirectCapturingResponseWrapper: addHeader called with '{}' = '{}'", name, value);
+ System.out.println("RedirectCapturingResponseWrapper: addHeader called with '" + name + "' = '" + value + "'");
+ if ("Location".equalsIgnoreCase(name)) {
+ this.redirectLocation = value;
+ } else {
+ super.addHeader(name, value);
+ }
+ }
+
+ public String getRedirectLocation() {
+ return redirectLocation;
+ }
+
+ public int getCapturedStatus() {
+ return statusCode;
+ }
+
+ public boolean isRedirect() {
+ return redirectLocation != null && (statusCode == 302 || statusCode == 301 || statusCode == 303 || statusCode == 307 || statusCode == 308);
+ }
+ }
+}
diff --git a/src/main/java/org/ohdsi/webapi/shiro/filters/AtlasRedirectSavedRequestHandler.java b/src/main/java/org/ohdsi/webapi/shiro/filters/AtlasRedirectSavedRequestHandler.java
new file mode 100644
index 0000000000..6f2e3a6f01
--- /dev/null
+++ b/src/main/java/org/ohdsi/webapi/shiro/filters/AtlasRedirectSavedRequestHandler.java
@@ -0,0 +1,150 @@
+package org.ohdsi.webapi.shiro.filters;
+
+import org.ohdsi.webapi.shiro.PermissionManager;
+import org.ohdsi.webapi.shiro.TokenManager;
+import org.ohdsi.webapi.util.UserUtils;
+import org.pac4j.core.context.CallContext;
+import org.pac4j.core.exception.http.FoundAction;
+import org.pac4j.core.exception.http.HttpAction;
+import org.pac4j.core.engine.savedrequest.SavedRequestHandler;
+import org.pac4j.core.profile.ProfileManager;
+import org.pac4j.core.profile.UserProfile;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Custom SavedRequestHandler that redirects to the Atlas UI URL with JWT token
+ * after successful OIDC authentication.
+ *
+ * This handler:
+ * 1. Extracts user info from the pac4j profile (from CallContext)
+ * 2. Registers the user in WebAPI
+ * 3. Generates a JWT token
+ * 4. Redirects to Atlas UI with the token in the URL
+ */
+public class AtlasRedirectSavedRequestHandler implements SavedRequestHandler {
+
+ private static final Logger logger = LoggerFactory.getLogger(AtlasRedirectSavedRequestHandler.class);
+
+ private final String atlasUrl;
+ private final PermissionManager permissionManager;
+ private final int tokenExpirationIntervalInSeconds;
+ private final Set defaultRoles;
+
+ public AtlasRedirectSavedRequestHandler(String atlasUrl, PermissionManager permissionManager,
+ int tokenExpirationIntervalInSeconds, Set defaultRoles) {
+ this.atlasUrl = atlasUrl;
+ this.permissionManager = permissionManager;
+ this.tokenExpirationIntervalInSeconds = tokenExpirationIntervalInSeconds;
+ this.defaultRoles = defaultRoles;
+ logger.info("AtlasRedirectSavedRequestHandler initialized with URL: {}", atlasUrl);
+ }
+
+ @Override
+ public void save(CallContext ctx) {
+ // Don't save the request - we always want to redirect to Atlas
+ logger.debug("AtlasRedirectSavedRequestHandler.save() called - ignoring save");
+ }
+
+ @Override
+ public HttpAction restore(CallContext ctx, String defaultUrl) {
+ String redirectUrl = atlasUrl != null ? atlasUrl : defaultUrl;
+
+ try {
+ // Get the user profile from pac4j's ProfileManager (not Shiro - auth hasn't happened yet)
+ ProfileManager profileManager = new ProfileManager(ctx.webContext(), ctx.sessionStore());
+ List profiles = profileManager.getProfiles();
+
+ logger.info("AtlasRedirectSavedRequestHandler: Found {} profiles", profiles.size());
+
+ if (!profiles.isEmpty()) {
+ UserProfile profile = profiles.get(0);
+
+ // Log all profile attributes for debugging
+ logger.info("AtlasRedirectSavedRequestHandler: Profile attributes: {}", profile.getAttributes());
+
+ // Try to get login from various attributes (prefer email for login identifier)
+ String login = (String) profile.getAttribute("email");
+ if (login == null) {
+ login = (String) profile.getAttribute("preferred_username");
+ }
+ if (login == null) {
+ login = (String) profile.getAttribute("username");
+ }
+ if (login == null) {
+ login = profile.getId();
+ }
+
+ // Try to get display name from various attributes
+ String name = (String) profile.getAttribute("name");
+ if (name == null) {
+ name = (String) profile.getAttribute("username");
+ }
+ if (name == null) {
+ name = (String) profile.getAttribute("nickname");
+ }
+ if (name == null) {
+ // Try combining given_name and family_name
+ String givenName = (String) profile.getAttribute("given_name");
+ String familyName = (String) profile.getAttribute("family_name");
+ if (givenName != null || familyName != null) {
+ name = ((givenName != null ? givenName : "") + " " + (familyName != null ? familyName : "")).trim();
+ }
+ }
+ String clientName = profile.getClientName();
+
+ logger.info("AtlasRedirectSavedRequestHandler: login={}, name={}, client={}",
+ login, name, clientName);
+
+ if (login != null) {
+ login = UserUtils.toLowerCase(login);
+
+ // Register user if needed
+ if (name == null) {
+ name = login;
+ }
+ permissionManager.registerUser(login, name, defaultRoles);
+
+ // Generate JWT token
+ Date expiration = getExpirationDate(tokenExpirationIntervalInSeconds);
+ String jwt = TokenManager.createJsonWebToken(login, null, expiration);
+
+ // Construct URL with token at hash root: {baseUrl}#/{clientName}/{jwt}
+ // The Vue Router expects the token at the hash root, not appended to an existing hash path
+ String baseUrl = redirectUrl;
+ int hashIndex = redirectUrl.indexOf('#');
+ if (hashIndex > 0) {
+ // Remove the hash and everything after it
+ baseUrl = redirectUrl.substring(0, hashIndex);
+ }
+ String urlWithToken = baseUrl.replaceAll("/+$", "") + "/#/" + clientName + "/" + jwt;
+ logger.info("AtlasRedirectSavedRequestHandler.restore() - redirecting with token to: {}",
+ urlWithToken.substring(0, Math.min(urlWithToken.length(), 100)) + "...");
+
+ return new FoundAction(urlWithToken);
+ } else {
+ logger.warn("AtlasRedirectSavedRequestHandler: No login identifier found in profile");
+ }
+ } else {
+ logger.warn("AtlasRedirectSavedRequestHandler: No profiles found in context");
+ }
+ } catch (Exception e) {
+ logger.error("AtlasRedirectSavedRequestHandler: Error generating token", e);
+ }
+
+ // Fallback: redirect without token
+ logger.info("AtlasRedirectSavedRequestHandler.restore() - redirecting without token to: {}", redirectUrl);
+ return new FoundAction(redirectUrl);
+ }
+
+ private Date getExpirationDate(int expirationIntervalInSeconds) {
+ Calendar calendar = Calendar.getInstance();
+ calendar.add(Calendar.SECOND, expirationIntervalInSeconds);
+ return calendar.getTime();
+ }
+}
diff --git a/src/main/java/org/ohdsi/webapi/shiro/filters/ExternalUrlOidcRedirectionActionBuilder.java b/src/main/java/org/ohdsi/webapi/shiro/filters/ExternalUrlOidcRedirectionActionBuilder.java
new file mode 100644
index 0000000000..d49c28cbfe
--- /dev/null
+++ b/src/main/java/org/ohdsi/webapi/shiro/filters/ExternalUrlOidcRedirectionActionBuilder.java
@@ -0,0 +1,71 @@
+package org.ohdsi.webapi.shiro.filters;
+
+import org.pac4j.core.context.CallContext;
+import org.pac4j.core.exception.http.RedirectionAction;
+import org.pac4j.core.exception.http.FoundAction;
+import org.pac4j.oidc.client.OidcClient;
+import org.pac4j.oidc.redirect.OidcRedirectionActionBuilder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Optional;
+
+/**
+ * Custom OIDC redirection action builder that rewrites authorization URLs
+ * to use an external base URL for browser-facing redirects.
+ *
+ * This is needed when WebAPI fetches OIDC discovery from an internal URL
+ * (which returns internal endpoints) but needs to redirect users to
+ * external browser-accessible URLs.
+ */
+public class ExternalUrlOidcRedirectionActionBuilder extends OidcRedirectionActionBuilder {
+
+ private static final Logger logger = LoggerFactory.getLogger(ExternalUrlOidcRedirectionActionBuilder.class);
+
+ private final String internalBaseUrl;
+ private final String externalBaseUrl;
+
+ public ExternalUrlOidcRedirectionActionBuilder(OidcClient client,
+ String internalBaseUrl, String externalBaseUrl) {
+ super(client);
+ this.internalBaseUrl = normalizeUrl(internalBaseUrl);
+ this.externalBaseUrl = normalizeUrl(externalBaseUrl);
+ logger.info("ExternalUrlOidcRedirectionActionBuilder initialized: internal={}, external={}",
+ this.internalBaseUrl, this.externalBaseUrl);
+ }
+
+ private String normalizeUrl(String url) {
+ if (url == null) {
+ return null;
+ }
+ // Remove .well-known suffix if present
+ if (url.contains("/.well-known/")) {
+ url = url.substring(0, url.indexOf("/.well-known/"));
+ }
+ // Remove trailing slash
+ return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
+ }
+
+ @Override
+ public Optional getRedirectionAction(CallContext ctx) {
+ Optional action = super.getRedirectionAction(ctx);
+
+ if (action.isPresent() && internalBaseUrl != null && externalBaseUrl != null
+ && !internalBaseUrl.equals(externalBaseUrl)) {
+ RedirectionAction originalAction = action.get();
+
+ if (originalAction instanceof FoundAction) {
+ FoundAction foundAction = (FoundAction) originalAction;
+ String originalLocation = foundAction.getLocation();
+
+ if (originalLocation != null && originalLocation.contains(internalBaseUrl)) {
+ String newLocation = originalLocation.replace(internalBaseUrl, externalBaseUrl);
+ logger.debug("Rewrote OIDC redirect URL from {} to {}", originalLocation, newLocation);
+ return Optional.of(new FoundAction(newLocation));
+ }
+ }
+ }
+
+ return action;
+ }
+}
diff --git a/src/main/java/org/ohdsi/webapi/shiro/management/AtlasRegularSecurity.java b/src/main/java/org/ohdsi/webapi/shiro/management/AtlasRegularSecurity.java
index f786c45203..5290807ebc 100644
--- a/src/main/java/org/ohdsi/webapi/shiro/management/AtlasRegularSecurity.java
+++ b/src/main/java/org/ohdsi/webapi/shiro/management/AtlasRegularSecurity.java
@@ -37,6 +37,10 @@
import org.pac4j.core.client.Clients;
import org.pac4j.core.config.Config;
import org.pac4j.core.http.callback.CallbackUrlResolver;
+import org.pac4j.core.profile.factory.ProfileManagerFactory;
+import org.pac4j.jee.context.JEEContextFactory;
+import org.pac4j.jee.context.session.JEESessionStoreFactory;
+import org.pac4j.jee.http.adapter.JEEHttpActionAdapter;
import org.pac4j.core.http.callback.PathParameterCallbackUrlResolver;
import org.pac4j.core.http.callback.QueryParameterCallbackUrlResolver;
import org.pac4j.http.client.direct.HeaderClient;
@@ -331,10 +335,23 @@ public Map getFilters() {
OidcConfiguration configuration = oidcConfCreator.build();
if (StringUtils.isNotBlank(configuration.getClientId())) {
// https://www.pac4j.org/4.0.x/docs/clients/openid-connect.html
- // OidcClient allows indirect login through UI with code flow
+ // OidcClient allows indirect login through UI with code flow
OidcClient oidcClient = new OidcClient(configuration);
oidcClient.setCallbackUrl(oauthApiCallback);
oidcClient.setCallbackUrlResolver(urlResolver);
+
+ // URL rewriting: discovery from internal URL, redirect to external URL
+ String internalUrl = configuration.getDiscoveryURI();
+ String externalUrl = oidcConfCreator.getExternalUrl();
+ if (externalUrl != null && !externalUrl.isEmpty()) {
+ org.ohdsi.webapi.shiro.filters.ExternalUrlOidcRedirectionActionBuilder redirectBuilder =
+ new org.ohdsi.webapi.shiro.filters.ExternalUrlOidcRedirectionActionBuilder(
+ oidcClient, internalUrl, externalUrl);
+ oidcClient.setRedirectionActionBuilder(redirectBuilder);
+ logger.info("Configured OIDC URL rewriting: internal={}, external={}", internalUrl, externalUrl);
+ }
+
+ // Configuration already initialized; pac4j handles lazy init
clients.add(oidcClient);
// Bearer token authentication for API access (pac4j 6.x)
@@ -355,6 +372,11 @@ public Map getFilters() {
clients
)
);
+ // Set Jakarta EE context factory for pac4j 6.x
+ cfg.setWebContextFactory(new JEEContextFactory());
+ cfg.setSessionStoreFactory(new JEESessionStoreFactory());
+ cfg.setProfileManagerFactory(ProfileManagerFactory.DEFAULT);
+ cfg.setHttpActionAdapter(JEEHttpActionAdapter.INSTANCE);
// assign clients to filters
if (this.googleAuthEnabled) {
@@ -390,8 +412,17 @@ public Map getFilters() {
filters.put(OIDC_DIRECT_AUTH, oidcDirectFilter);
}
- CallbackFilter callbackFilter = new CallbackFilter();
+ io.buji.pac4j.filter.CallbackFilter callbackFilter = new io.buji.pac4j.filter.CallbackFilter();
callbackFilter.setConfig(cfg);
+ callbackFilter.setDefaultUrl(this.oauthUiCallback);
+ callbackFilter.setSavedRequestHandler(
+ new org.ohdsi.webapi.shiro.filters.AtlasRedirectSavedRequestHandler(
+ this.oauthUiCallback,
+ this.authorizer,
+ this.tokenExpirationIntervalInSeconds,
+ this.defaultRoles
+ )
+ );
filters.put(OAUTH_CALLBACK, callbackFilter);
filters.put(HANDLE_UNSUCCESSFUL_OAUTH, new RedirectOnFailedOAuthFilter(this.oauthUiCallback));
}
@@ -570,6 +601,10 @@ private SAML2Client setUpSamlClient(Map filters, Filter
final SAML2Client saml2Client = new SAML2Client(cfg);
Config samlCfg = new Config(new Clients(samlCallbackUrl, saml2Client));
+ samlCfg.setWebContextFactory(new JEEContextFactory());
+ samlCfg.setSessionStoreFactory(new JEESessionStoreFactory());
+ samlCfg.setProfileManagerFactory(ProfileManagerFactory.DEFAULT);
+ samlCfg.setHttpActionAdapter(JEEHttpActionAdapter.INSTANCE);
SecurityFilter samlAuthFilter = new SecurityFilter();
samlAuthFilter.setConfig(samlCfg);
@@ -641,6 +676,10 @@ private void setUpCAS(Map filters) {
CasClient casClient = new CasClient(casConf);
Config casCfg = new Config(new Clients(casCallbackUrl, casClient));
+ casCfg.setWebContextFactory(new JEEContextFactory());
+ casCfg.setSessionStoreFactory(new JEESessionStoreFactory());
+ casCfg.setProfileManagerFactory(ProfileManagerFactory.DEFAULT);
+ casCfg.setHttpActionAdapter(JEEHttpActionAdapter.INSTANCE);
/**
* CAS filter
diff --git a/src/main/java/org/ohdsi/webapi/shiro/management/AtlasSecurity.java b/src/main/java/org/ohdsi/webapi/shiro/management/AtlasSecurity.java
index ed26db1c0c..4842225393 100644
--- a/src/main/java/org/ohdsi/webapi/shiro/management/AtlasSecurity.java
+++ b/src/main/java/org/ohdsi/webapi/shiro/management/AtlasSecurity.java
@@ -96,6 +96,8 @@ protected FilterChainBuilder setupProtectedPaths(FilterChainBuilder filterChainB
return filterChainBuilder
// version info
.addRestPath("/info")
+ // auth providers (for Atlas frontend)
+ .addRestPath("/auth/providers")
// DDL service
.addRestPath("/ddl/results")
.addRestPath("/ddl/cemresults")
diff --git a/src/main/java/org/ohdsi/webapi/source/SourceController.java b/src/main/java/org/ohdsi/webapi/source/SourceController.java
index 27466d7d5c..b2b2a714ad 100644
--- a/src/main/java/org/ohdsi/webapi/source/SourceController.java
+++ b/src/main/java/org/ohdsi/webapi/source/SourceController.java
@@ -350,7 +350,8 @@ public Response delete(@PathParam("sourceId") Integer sourceId) throws Exception
public SourceInfo checkConnection(@PathParam("key") final String sourceKey) {
final Source source = sourceService.findBySourceKey(sourceKey);
- sourceService.checkConnection(source);
+ // Explicit endpoint call bypasses checkConnection flag
+ sourceService.forceCheckConnection(source);
return source.getSourceInfo();
}
diff --git a/src/main/java/org/ohdsi/webapi/source/SourceService.java b/src/main/java/org/ohdsi/webapi/source/SourceService.java
index a953cd88c1..6c4cc3d404 100644
--- a/src/main/java/org/ohdsi/webapi/source/SourceService.java
+++ b/src/main/java/org/ohdsi/webapi/source/SourceService.java
@@ -124,11 +124,19 @@ public Map getSourcesMap(SourceMapKey mapKey) {
public void checkConnection(Source source) {
if (source.isCheckConnection()) {
- final JdbcTemplate jdbcTemplate = getSourceJdbcTemplate(source);
- jdbcTemplate.execute(SqlTranslate.translateSql("select 1;", source.getSourceDialect()).replaceAll(";$", ""));
+ forceCheckConnection(source);
}
}
+ /**
+ * Force a connection check regardless of the source's checkConnection flag.
+ * Used when explicitly testing connection via API endpoint.
+ */
+ public void forceCheckConnection(Source source) {
+ final JdbcTemplate jdbcTemplate = getSourceJdbcTemplate(source);
+ jdbcTemplate.execute(SqlTranslate.translateSql("select 1;", source.getSourceDialect()).replaceAll(";$", ""));
+ }
+
public Source getPrioritySourceForDaimon(SourceDaimon.DaimonType daimonType) {
List sourcesByDaimonPriority = sourceRepository.findAllSortedByDiamonPrioirty(daimonType);
diff --git a/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLInstanceManager.java b/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLInstanceManager.java
index f6ed47cfd1..e6112a8414 100644
--- a/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLInstanceManager.java
+++ b/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLInstanceManager.java
@@ -11,10 +11,6 @@
import java.util.Map;
import java.util.concurrent.locks.ReentrantLock;
-/**
- * Singleton manager for the TrexSQL instance.
- * Provides lazy initialization and graceful shutdown.
- */
@Component
@ConditionalOnProperty(name = "trexsql.enabled", havingValue = "true", matchIfMissing = false)
public class TrexSQLInstanceManager {
@@ -22,31 +18,31 @@ public class TrexSQLInstanceManager {
private static final Logger log = LoggerFactory.getLogger(TrexSQLInstanceManager.class);
private final TrexSQLConfig config;
- private volatile Object trexsqlDb = null;
+ private volatile boolean initialized = false;
+ private volatile boolean initFailed = false;
private final ReentrantLock initLock = new ReentrantLock();
public TrexSQLInstanceManager(TrexSQLConfig config) {
this.config = config;
}
- private volatile boolean initFailed = false;
-
- public Object getInstance() {
+ public void ensureInitialized() {
if (!config.isEnabled()) {
throw new IllegalStateException("TrexSQL is not enabled");
}
if (initFailed) {
- return null;
+ return;
}
- if (trexsqlDb == null) {
+ if (!initialized) {
initLock.lock();
try {
- if (trexsqlDb == null && !initFailed) {
+ if (!initialized && !initFailed) {
log.info("Initializing TrexSQL instance");
try {
- trexsqlDb = Trexsql.init(buildConfig());
+ Trexsql.init(buildConfig());
+ initialized = true;
log.info("TrexSQL instance initialized successfully");
} catch (Exception | Error e) {
log.error("Failed to initialize TrexSQL: {}. TrexSQL features will be unavailable.", e.getMessage());
@@ -57,15 +53,14 @@ public Object getInstance() {
initLock.unlock();
}
}
- return trexsqlDb;
}
public boolean isAvailable() {
- if (!config.isEnabled() || trexsqlDb == null) {
+ if (!config.isEnabled() || !initialized) {
return false;
}
try {
- return Trexsql.isRunning(trexsqlDb);
+ return Trexsql.isRunning();
} catch (Exception e) {
log.warn("Error checking TrexSQL status: {}", e.getMessage());
return false;
@@ -73,11 +68,11 @@ public boolean isAvailable() {
}
public boolean isAttached(String databaseCode) {
- if (trexsqlDb == null) {
+ if (!initialized) {
return false;
}
try {
- return Trexsql.isAttached(trexsqlDb, databaseCode);
+ return Trexsql.isAttached(databaseCode);
} catch (Exception e) {
log.warn("Error checking if database {} is attached: {}", databaseCode, e.getMessage());
return false;
@@ -104,15 +99,15 @@ private Map buildConfig() {
public void shutdown() {
initLock.lock();
try {
- if (trexsqlDb != null) {
+ if (initialized) {
log.info("Shutting down TrexSQL instance");
try {
- Trexsql.shutdown(trexsqlDb);
+ Trexsql.shutdown();
log.info("TrexSQL instance shut down successfully");
} catch (Exception e) {
log.error("Error shutting down TrexSQL instance: {}", e.getMessage(), e);
} finally {
- trexsqlDb = null;
+ initialized = false;
}
}
} finally {
diff --git a/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLService.java b/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLService.java
index 71698edb1b..395bfc7017 100644
--- a/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLService.java
+++ b/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLService.java
@@ -49,8 +49,8 @@ public List