From aed3413c9121181acdfa6984aa59f87e0b4510aa Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Fri, 5 Jun 2026 11:10:09 -0700 Subject: [PATCH 1/4] Service-side reauthentication infrastructure --- .../labkey/api/action/SimpleErrorView.java | 2 - .../security/AuthenticationConfiguration.java | 1 + .../api/security/AuthenticationManager.java | 51 +++++++++ .../api/security/AuthenticationProvider.java | 12 +++ .../org/labkey/api/security/LoginUrls.java | 1 + .../labkey/core/login/LoginController.java | 102 +++++++++++++----- .../org/labkey/devtools/TestController.java | 36 +++++++ .../authentication/TestSsoConfiguration.java | 7 ++ .../org/labkey/devtools/view/testReauth.jsp | 76 +++++++++++++ 9 files changed, 260 insertions(+), 28 deletions(-) create mode 100644 devtools/src/org/labkey/devtools/view/testReauth.jsp diff --git a/api/src/org/labkey/api/action/SimpleErrorView.java b/api/src/org/labkey/api/action/SimpleErrorView.java index b7f58fcd742..b50c5cea200 100644 --- a/api/src/org/labkey/api/action/SimpleErrorView.java +++ b/api/src/org/labkey/api/action/SimpleErrorView.java @@ -23,8 +23,6 @@ /** * View that renders an error collection. - * User: adam - * Date: Sep 26, 2007 */ public class SimpleErrorView extends JspView { diff --git a/api/src/org/labkey/api/security/AuthenticationConfiguration.java b/api/src/org/labkey/api/security/AuthenticationConfiguration.java index 9cc866d7432..d308e98c9da 100644 --- a/api/src/org/labkey/api/security/AuthenticationConfiguration.java +++ b/api/src/org/labkey/api/security/AuthenticationConfiguration.java @@ -114,6 +114,7 @@ interface SSOAuthenticationConfiguration { LinkFactory getLinkFactory(); URLHelper getUrl(ViewContext ctx); + URLHelper getReauthUrl(ViewContext ctx); /** * Allows an SSO auth configuration to specify that it should be used automatically instead of showing the standard diff --git a/api/src/org/labkey/api/security/AuthenticationManager.java b/api/src/org/labkey/api/security/AuthenticationManager.java index c6051d89850..3a5c1d509bb 100644 --- a/api/src/org/labkey/api/security/AuthenticationManager.java +++ b/api/src/org/labkey/api/security/AuthenticationManager.java @@ -86,6 +86,7 @@ import org.labkey.api.usageMetrics.UsageMetricsService; import org.labkey.api.util.DateUtil; import org.labkey.api.util.ExceptionUtil; +import org.labkey.api.util.GUID; import org.labkey.api.util.HeartBeat; import org.labkey.api.util.HtmlString; import org.labkey.api.util.HtmlStringBuilder; @@ -549,7 +550,12 @@ public static abstract class BaseSsoValidateAction
_userAttributeMap = Collections.emptyMap(); // A case-insensitive map of attribute names and values associated with the user private @NotNull Map _authenticationProperties = Collections.emptyMap(); private boolean _requireSecondary = true; // Require secondary authentication + private boolean _reauth = false; private @Nullable String _successDetails = null; // An optional string describing how successful authentication took place, which will // appear in the audit log. If null, the configuration's description will be used. @@ -447,6 +448,17 @@ public AuthenticationResponse setRequireSecondary(boolean requireSecondary) _requireSecondary = requireSecondary; return this; } + + public boolean isReauth() + { + return _reauth; + } + + public AuthenticationResponse setReauth(boolean reauth) + { + _reauth = reauth; + return this; + } } // FailureReasons are only reported to administrators (in the audit log and/or server log), NOT to users (and potential diff --git a/api/src/org/labkey/api/security/LoginUrls.java b/api/src/org/labkey/api/security/LoginUrls.java index 856ffb05afe..52bc43b6d4f 100644 --- a/api/src/org/labkey/api/security/LoginUrls.java +++ b/api/src/org/labkey/api/security/LoginUrls.java @@ -41,4 +41,5 @@ public interface LoginUrls extends UrlProvider ActionURL getStopImpersonatingURL(Container c, @Nullable URLHelper returnUrl); ActionURL getAgreeToTermsURL(Container c, URLHelper returnUrl); ActionURL getSSORedirectURL(SSOAuthenticationConfiguration configuration, URLHelper returnUrl, boolean skipProfile); + ActionURL getSSOReauthURL(SSOAuthenticationConfiguration configuration, URLHelper returnUrl); } diff --git a/core/src/org/labkey/core/login/LoginController.java b/core/src/org/labkey/core/login/LoginController.java index 5e1e40e98d5..a3ddd86fa12 100644 --- a/core/src/org/labkey/core/login/LoginController.java +++ b/core/src/org/labkey/core/login/LoginController.java @@ -25,6 +25,7 @@ import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.Nullable; import org.json.JSONArray; +import org.json.JSONObject; import org.labkey.api.action.ApiResponse; import org.labkey.api.action.ApiSimpleResponse; import org.labkey.api.action.ApiUsageException; @@ -52,6 +53,7 @@ import org.labkey.api.security.ActionNames; import org.labkey.api.security.AdminConsoleAction; import org.labkey.api.security.AuthenticationConfiguration.LoginFormAuthenticationConfiguration; +import org.labkey.api.security.AuthenticationConfiguration.PrimaryAuthenticationConfiguration; import org.labkey.api.security.AuthenticationConfiguration.SSOAuthenticationConfiguration; import org.labkey.api.security.AuthenticationConfiguration.SecondaryAuthenticationConfiguration; import org.labkey.api.security.AuthenticationConfigurationCache; @@ -72,6 +74,7 @@ import org.labkey.api.security.MutableSecurityPolicy; import org.labkey.api.security.PasswordExpiration; import org.labkey.api.security.PasswordRule; +import org.labkey.api.security.RequiresLogin; import org.labkey.api.security.RequiresNoPermission; import org.labkey.api.security.RequiresPermission; import org.labkey.api.security.SecurityManager; @@ -115,6 +118,7 @@ import org.labkey.api.view.RedirectException; import org.labkey.api.view.UnsafeExternalRedirectException; import org.labkey.api.view.VBox; +import org.labkey.api.view.ViewContext; import org.labkey.api.view.WebPartView; import org.labkey.api.view.template.PageConfig; import org.labkey.api.wiki.WikiRendererType; @@ -301,12 +305,24 @@ public ActionURL getAgreeToTermsURL(Container c, URLHelper returnUrl) @Override public ActionURL getSSORedirectURL(SSOAuthenticationConfiguration configuration, URLHelper returnUrl, boolean skipProfile) { - ActionURL url = new ActionURL(SsoRedirectAction.class, ContainerManager.getRoot()); - url.addParameter("configuration", configuration.getRowId()); + ActionURL url = getRedirectURL(SsoRedirectAction.class, configuration, returnUrl); if (skipProfile) { url.addParameter("skipProfile", 1); } + return url; + } + + @Override + public ActionURL getSSOReauthURL(SSOAuthenticationConfiguration configuration, URLHelper returnUrl) + { + return getRedirectURL(SsoReauthAction.class, configuration, returnUrl); + } + + private ActionURL getRedirectURL(Class redirectActionClass, SSOAuthenticationConfiguration configuration, @Nullable URLHelper returnUrl) + { + ActionURL url = new ActionURL(redirectActionClass, ContainerManager.getRoot()); + url.addParameter("configuration", configuration.getRowId()); if (null != returnUrl) { String fragment = returnUrl.getFragment(); @@ -707,9 +723,7 @@ else if (form.getTermsOfUseType() == TermsOfUseType.SITE_WIDE) if (form.isForceReauth()) { - String reauthToken = GUID.makeHash(); - redirectUrl.addParameter(REAUTH_TOKEN_NAME, reauthToken); - request.getSession().setAttribute(REAUTH_TOKEN_NAME, new Reauth(reauthToken, user)); + AuthenticationManager.setReauthUser(user, request, redirectUrl); } // Use the full hostname in the URL if we have one, otherwise just go with a local URI @@ -1539,20 +1553,8 @@ public Object execute(ReturnUrlForm form, BindException errors) public static class SsoRedirectForm extends AbstractLoginForm { - private String _provider; private int _configuration; - public String getProvider() - { - return _provider; - } - - @SuppressWarnings("unused") - public void setProvider(String provider) - { - _provider = provider; - } - public int getConfiguration() { return _configuration; @@ -1565,18 +1567,13 @@ public void setConfiguration(int configuration) } } - @RequiresNoPermission - @AllowedDuringUpgrade - // Always invoked in the root, so no need to ignore locked projects - public static class SsoRedirectAction extends SimpleViewAction + private static abstract class BaseSsoRedirectAction extends SimpleViewAction { + protected abstract URLHelper getUrl(SSOAuthenticationConfiguration configuration, ViewContext context); + @Override public ModelAndView getView(SsoRedirectForm form, BindException errors) { - // If logged in then redirect immediately - if (!getUser().isGuest()) - return HttpView.redirect(form.getReturnActionURL(AppProps.getInstance().getHomePageActionURL())); - // If we have a returnUrl or skipProfile param then create and stash LoginReturnProperties URLHelper returnUrl = form.getReturnUrlHelper(); if (null != returnUrl || form.getSkipProfile()) @@ -1592,7 +1589,7 @@ public ModelAndView getView(SsoRedirectForm form, BindException errors) if (null == configuration) throw new NotFoundException("Authentication configuration is not valid"); - url = configuration.getUrl(getViewContext()); + url = getUrl(configuration, getViewContext()); // It's safe to bypass checking the external redirect allow list in this case because we are redirecting to // the administrator-provided URL from the SSO authentication configuration. @@ -1605,6 +1602,59 @@ public final void addNavTrail(NavTree root) } } + @RequiresNoPermission + @AllowedDuringUpgrade + // Always invoked in the root, so no need to ignore locked projects + public static class SsoRedirectAction extends BaseSsoRedirectAction + { + @Override + public ModelAndView getView(SsoRedirectForm form, BindException errors) + { + // If logged in then redirect immediately + if (!getUser().isGuest()) + return HttpView.redirect(form.getReturnActionURL(AppProps.getInstance().getHomePageActionURL())); + + return super.getView(form, errors); + } + + @Override + protected URLHelper getUrl(SSOAuthenticationConfiguration configuration, ViewContext context) + { + return configuration.getUrl(context); + } + } + + // Very similar to SsoRedirectAction, but needs different annotations, so we have two separate classes + @RequiresLogin + public static class SsoReauthAction extends BaseSsoRedirectAction + { + @Override + protected URLHelper getUrl(SSOAuthenticationConfiguration configuration, ViewContext context) + { + return configuration.getReauthUrl(context); + } + } + + @SuppressWarnings("unused") // Called from client code + @RequiresLogin + public static class GetAuthenticationConfigurationAction extends ReadOnlyApiAction + { + @Override + public Object execute(ReturnUrlForm form, BindException errors) + { + PrimaryAuthenticationConfiguration configuration = AuthenticationManager.getConfiguration(getViewContext().getSession()); + if (configuration == null) + { + throw new RuntimeException("No configuration found"); + } + JSONObject resp = new JSONObject(); + resp.put("description", configuration.getDescription()); + if (configuration instanceof SSOAuthenticationConfiguration sso) + resp.put("reauthUrl", urlProvider(LoginUrls.class).getSSOReauthURL(sso, form.getReturnActionURL())); + return success(resp); + } + } + public static final String PASSWORD1_TEXT_FIELD_NAME = "password"; public static final String PASSWORD2_TEXT_FIELD_NAME = "password2"; diff --git a/devtools/src/org/labkey/devtools/TestController.java b/devtools/src/org/labkey/devtools/TestController.java index 1240c01fb0e..c78d22976cd 100644 --- a/devtools/src/org/labkey/devtools/TestController.java +++ b/devtools/src/org/labkey/devtools/TestController.java @@ -19,6 +19,7 @@ import jakarta.servlet.http.HttpServletResponse; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.junit.Assert; import org.junit.Test; import org.labkey.api.action.ApiResponse; @@ -1492,4 +1493,39 @@ public void testJacksonInputLimitExceeded() throws Exception } } + public record ReauthForm(@Nullable String reauthToken, @Nullable String errorMessage){} + + @RequiresLogin + public static class TestReauthAction extends FormViewAction + { + @Override + public void validateCommand(ReauthForm form, Errors errors) + { + } + + @Override + public ModelAndView getView(ReauthForm form, boolean reshow, BindException errors) + { + getPageConfig().setTemplate(PageConfig.Template.Dialog); + return new JspView<>("/org/labkey/devtools/view/testReauth.jsp", form, errors); + } + + @Override + public boolean handlePost(ReauthForm form, BindException errors) + { + // TODO: Validate the reauthToken here - push into errors + return false; + } + + @Override + public URLHelper getSuccessURL(ReauthForm form) + { + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + } + } } diff --git a/devtools/src/org/labkey/devtools/authentication/TestSsoConfiguration.java b/devtools/src/org/labkey/devtools/authentication/TestSsoConfiguration.java index 34da73011d4..48d777e6ef0 100644 --- a/devtools/src/org/labkey/devtools/authentication/TestSsoConfiguration.java +++ b/devtools/src/org/labkey/devtools/authentication/TestSsoConfiguration.java @@ -15,6 +15,7 @@ */ package org.labkey.devtools.authentication; +import org.apache.commons.lang3.NotImplementedException; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.labkey.api.data.ContainerManager; @@ -53,6 +54,12 @@ public URLHelper getUrl(ViewContext ctx) return url; } + @Override + public URLHelper getReauthUrl(ViewContext ctx) + { + throw new NotImplementedException(); + } + @Override public LinkFactory getLinkFactory() { diff --git a/devtools/src/org/labkey/devtools/view/testReauth.jsp b/devtools/src/org/labkey/devtools/view/testReauth.jsp new file mode 100644 index 00000000000..29342cb52fa --- /dev/null +++ b/devtools/src/org/labkey/devtools/view/testReauth.jsp @@ -0,0 +1,76 @@ +<% +/* + * Copyright (c) 2014-2026 LabKey Corporation + * + * 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. + */ +%> +<%@ page import="org.labkey.api.view.ActionURL" %> +<%@ page import="org.labkey.api.view.HttpView" %> +<%@ page import="org.labkey.api.view.JspView" %> +<%@ page import="org.labkey.devtools.TestController.ReauthForm" %> +<%@ page import="org.labkey.devtools.TestController.TestReauthAction" %> +<%@ taglib prefix="labkey" uri="http://www.labkey.org/taglib" %> +<%@ page extends="org.labkey.api.jsp.JspBase" %> +<% + JspView me = HttpView.currentView(); + ReauthForm form = me.getModelBean(); + ActionURL formURL = urlFor(TestReauthAction.class); +%> + + +You authenticated with:
+ +<% + if (form.reauthToken() != null) + { +%> +Looks like you successfully re-authenticated and received token: <%=h(form.reauthToken())%> +<% + } + else if (form.errorMessage() != null) + { +%> +Looks like your reauthentication failed: <%=h(form.errorMessage())%>. Try again? +<% + } + else + { +%> +Looks like you need to re-authenticate. +<% + } +%> + From c5e76d87c5309fb64469312641bb96c1e8a6f41c Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Fri, 5 Jun 2026 11:44:22 -0700 Subject: [PATCH 2/4] Validate reauthToken to complete flow --- devtools/src/org/labkey/devtools/TestController.java | 12 ++++++++---- devtools/src/org/labkey/devtools/view/testReauth.jsp | 8 ++++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/devtools/src/org/labkey/devtools/TestController.java b/devtools/src/org/labkey/devtools/TestController.java index c78d22976cd..caaad89d161 100644 --- a/devtools/src/org/labkey/devtools/TestController.java +++ b/devtools/src/org/labkey/devtools/TestController.java @@ -41,12 +41,14 @@ import org.labkey.api.mcp.AbstractAgentAction; import org.labkey.api.mcp.McpService; import org.labkey.api.security.AdminConsoleAction; +import org.labkey.api.security.AuthenticationManager; import org.labkey.api.security.CSRF; import org.labkey.api.security.MethodsAllowed; import org.labkey.api.security.RequiresLogin; import org.labkey.api.security.RequiresNoPermission; import org.labkey.api.security.RequiresPermission; import org.labkey.api.security.RequiresSiteAdmin; +import org.labkey.api.security.User; import org.labkey.api.security.permissions.AdminOperationsPermission; import org.labkey.api.security.permissions.AdminPermission; import org.labkey.api.security.permissions.DeletePermission; @@ -1496,7 +1498,7 @@ public void testJacksonInputLimitExceeded() throws Exception public record ReauthForm(@Nullable String reauthToken, @Nullable String errorMessage){} @RequiresLogin - public static class TestReauthAction extends FormViewAction + public class TestReauthAction extends FormViewAction { @Override public void validateCommand(ReauthForm form, Errors errors) @@ -1513,14 +1515,16 @@ public ModelAndView getView(ReauthForm form, boolean reshow, BindException error @Override public boolean handlePost(ReauthForm form, BindException errors) { - // TODO: Validate the reauthToken here - push into errors - return false; + User reauthUser = AuthenticationManager.getAndClearReauthUser(getViewContext().getRequestOrThrow(), form.reauthToken()); + if (reauthUser == null) + throw new NotFoundException("Reauthentication validation failed!"); + return true; } @Override public URLHelper getSuccessURL(ReauthForm form) { - return null; + return actionURL(BeginAction.class); } @Override diff --git a/devtools/src/org/labkey/devtools/view/testReauth.jsp b/devtools/src/org/labkey/devtools/view/testReauth.jsp index 29342cb52fa..15e51bff28c 100644 --- a/devtools/src/org/labkey/devtools/view/testReauth.jsp +++ b/devtools/src/org/labkey/devtools/view/testReauth.jsp @@ -25,7 +25,6 @@ <% JspView me = HttpView.currentView(); ReauthForm form = me.getModelBean(); - ActionURL formURL = urlFor(TestReauthAction.class); %>