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> searchVocab(String sourceKey, String searchTerm options.put("cache-path", config.getCachePath()); try { - Object db = instanceManager.getInstance(); - List> results = Trexsql.searchVocab(db, searchTerm, options); + instanceManager.ensureInitialized(); + List> results = Trexsql.searchVocab(searchTerm, options); log.debug("Vocabulary search returned {} results", results.size()); return results; } catch (Exception e) { diff --git a/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLServletConfig.java b/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLServletConfig.java index 5fb95bebec..f4c8d11286 100644 --- a/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLServletConfig.java +++ b/src/main/java/org/ohdsi/webapi/trexsql/TrexSQLServletConfig.java @@ -27,13 +27,15 @@ public ServletRegistrationBean trexServlet( TrexSQLConfig trexConfig, SourceRepository sourceRepository) { + instanceManager.ensureInitialized(); + TrexServlet servlet = new TrexServlet(); Map config = new HashMap<>(); String cachePath = trexConfig.getCachePath(); log.info("TrexSQL cache path configured as: {}", cachePath); config.put("cache-path", cachePath); - servlet.initTrex(instanceManager.getInstance(), sourceRepository, config); + servlet.initTrex(sourceRepository, config); ServletRegistrationBean registration = new ServletRegistrationBean<>(servlet, "/WebAPI/trexsql/*"); diff --git a/src/main/resources/resources/job/sql/jobExecutions.sql b/src/main/resources/resources/job/sql/jobExecutions.sql index 51fc5a9a20..771f4a9add 100644 --- a/src/main/resources/resources/job/sql/jobExecutions.sql +++ b/src/main/resources/resources/job/sql/jobExecutions.sql @@ -1,4 +1,4 @@ -select E.JOB_EXECUTION_ID, E.START_TIME, E.END_TIME, E.STATUS, E.EXIT_CODE, E.EXIT_MESSAGE, E.CREATE_TIME, E.LAST_UPDATED, E.VERSION, I.JOB_INSTANCE_ID, I.JOB_NAME, P.KEY_NAME, P.TYPE_CD, P.STRING_VAL, P.DATE_VAL, P.LONG_VAL, P.DOUBLE_VAL, P.IDENTIFYING +select E.JOB_EXECUTION_ID, E.START_TIME, E.END_TIME, E.STATUS, E.EXIT_CODE, E.EXIT_MESSAGE, E.CREATE_TIME, E.LAST_UPDATED, E.VERSION, I.JOB_INSTANCE_ID, I.JOB_NAME, P.PARAMETER_NAME, P.PARAMETER_TYPE, P.PARAMETER_VALUE, P.IDENTIFYING from @ohdsi_schema.BATCH_JOB_EXECUTION E join @ohdsi_schema.BATCH_JOB_INSTANCE I on I.JOB_INSTANCE_ID = E.JOB_INSTANCE_ID left outer join @ohdsi_schema.BATCH_JOB_EXECUTION_PARAMS P on P.JOB_EXECUTION_ID = E.JOB_EXECUTION_ID