This is an automated email from the ASF dual-hosted git repository. sbglasius pushed a commit to branch fix/cas-single-signout-and-proxy-receptor in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit 95a9eb6f1500d98435414588b8aa169626a95d56 Author: Søren Berg Glasius <[email protected]> AuthorDate: Thu Aug 20 22:18:49 2026 +0200 fix(cas): address review feedback on metadata, docs, timeouts and style * Drop the author metadata, per ASF policy. * Restore log.warn alongside the println for the single signout warning, as SpringSecurityCoreGrailsPlugin does. The @Slf4j annotation was otherwise unused, and an application that captures logs but not stdout would never see the warning the docs promise. Trim a whitespace-only line. * Update the "Single Signout" section of the CAS guide, which still said the feature was enabled by default and only documented turning it off. * Renumber the upgrade-guide section to 48; sections 45 to 47 landed upstream while this was open, leaving two sections numbered 46. * Give the CAS requests in the specs a per-request timeout. Only a connect timeout was set, so a container that accepted the connection but never answered would block send() indefinitely and hang the job until the CI runner killed it. * Forward TESTCONFIG and casContainerVersion to bootRun, which forks its own JVM and so never saw them. /secure/proxyStatus could therefore only ever report NO_PROXY_TICKET when running the app by hand. Documented in the README, and verified that bootRun -DTESTCONFIG=casProxy now yields a real proxy ticket. * Reuse the base class's execution-token extraction in CasLoginSpec instead of duplicating it without its null guard, which turned a missing token into an opaque NPE inside URLEncoder.encode. * Correct the CasSingleSignOutSpec class comment, stale since single signout became opt-in. * Reindent SecureController with spaces to match .editorconfig. --- .../securityPlugins/springSecurity/cas/usage.adoc | 18 +++++++- .../src/en/guide/upgrading/upgrading80x.adoc | 2 +- .../cas/SpringSecurityCasGrailsPlugin.groovy | 5 +-- grails-test-examples/spring-security/cas/README.md | 9 ++++ .../spring-security/cas/test1/build.gradle | 12 ++++++ .../cas/test/SecureController.groovy | 50 +++++++++++----------- .../springsecurity/cas/test/AbstractCasSpec.groovy | 11 ++++- .../springsecurity/cas/test/CasLoginSpec.groovy | 3 +- .../cas/test/CasSingleSignOutSpec.groovy | 6 +-- 9 files changed, 79 insertions(+), 37 deletions(-) diff --git a/grails-doc/src/en/guide/security/securityPlugins/springSecurity/cas/usage.adoc b/grails-doc/src/en/guide/security/securityPlugins/springSecurity/cas/usage.adoc index b4990fe56f..1baf9f305c 100644 --- a/grails-doc/src/en/guide/security/securityPlugins/springSecurity/cas/usage.adoc +++ b/grails-doc/src/en/guide/security/securityPlugins/springSecurity/cas/usage.adoc @@ -59,7 +59,20 @@ grails: === Single Signout -Single signout is enabled by default and enables signing out for all CAS-managed applications with one logout. This works best in the plugin when combined with the `afterLogoutUrl` parameter, for example: +Single signout enables signing out of all CAS-managed applications with one logout. It is opt-in, because enabling it disables session fixation prevention - CAS maps the service ticket to the HTTP session id, so a logout request cannot be matched to a session that was replaced when the user authenticated: + +[source,java] +---- +grails: + plugin: + springsecurity: + cas: + useSingleSignout: true +---- + +The plugin prints and logs a warning at startup when it is enabled, so the trade-off is visible. + +It works best when combined with the `afterLogoutUrl` parameter, for example: [source,java] ---- @@ -72,7 +85,8 @@ grails: With this configuration, when a user logs out locally by navigating to `/logout/` they'll then be redirected to the CAS server's logout URL. This request includes a local URL to redirect back afterwards. When the whole process is finished they'll be logged out locally and at the CAS server, so subsequent secure URLs at the local server or other CAS-managed servers will require a new login. -If you don't want the single signout filter registered, you can disable the feature: +To go back to the default and leave the single signout filter unregistered, keeping session fixation +prevention in place, set it to `false` or remove the setting: [source,java] ---- diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index 28a52e30d5..867c7b4783 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -2633,7 +2633,7 @@ used to apply to its own message source. Adding or removing a base name now needs a restart, because Spring Boot reads the configured base names once when it builds the message source. -==== 46. CAS Single Sign-Out Is Opt-In +==== 48. CAS Single Sign-Out Is Opt-In `grails.plugin.springsecurity.cas.useSingleSignout` now defaults to `false`. It previously defaulted to `true`, so every CAS application registered the CAS client's `SingleSignOutFilter` whether or not diff --git a/grails-spring-security/cas/plugin/src/main/groovy/grails/plugin/springsecurity/cas/SpringSecurityCasGrailsPlugin.groovy b/grails-spring-security/cas/plugin/src/main/groovy/grails/plugin/springsecurity/cas/SpringSecurityCasGrailsPlugin.groovy index 516ab47e8c..e8f297ccfe 100644 --- a/grails-spring-security/cas/plugin/src/main/groovy/grails/plugin/springsecurity/cas/SpringSecurityCasGrailsPlugin.groovy +++ b/grails-spring-security/cas/plugin/src/main/groovy/grails/plugin/springsecurity/cas/SpringSecurityCasGrailsPlugin.groovy @@ -48,8 +48,6 @@ import grails.plugins.Plugin class SpringSecurityCasGrailsPlugin extends Plugin { String grailsVersion = '8.0.0-SNAPSHOT > *' - String author = 'Burt Beckwith' - String authorEmail = '' String title = 'Apereo CAS support for the Spring Security plugin.' String description = 'Apereo CAS support for the Spring Security plugin.' String documentation = 'https://apache.github.io/grails-spring-security' @@ -101,7 +99,8 @@ class SpringSecurityCasGrailsPlugin extends Plugin { session fixation prevention and handle logout in the application instead. ''' println message - + log.warn message + // Setting the config value is not enough on its own: this plugin loads after // springSecurityCore, which has already defined sessionAuthenticationStrategy from // the original value. The bean is therefore redefined here as well, the same way diff --git a/grails-test-examples/spring-security/cas/README.md b/grails-test-examples/spring-security/cas/README.md index 805332ab74..e4c2d2502f 100644 --- a/grails-test-examples/spring-security/cas/README.md +++ b/grails-test-examples/spring-security/cas/README.md @@ -65,6 +65,15 @@ The test application URLs are: * [http://localhost:8081/secure/users](http://localhost:8081/secure/users) * [http://localhost:8081/secure/proxyStatus](http://localhost:8081/secure/proxyStatus) — asks CAS for a proxy ticket +`/secure/proxyStatus` needs the proxy configuration, which is only applied when a configuration that +enables it is selected. Without one it reports `NO_PROXY_TICKET`, because no proxy callback URL was +configured and CAS was never asked for a proxy-granting ticket. To exercise it, pass the +configuration to `bootRun` as well: + +``` +./gradlew :grails-test-examples-spring-security-cas-test1:bootRun -DTESTCONFIG=casProxy +``` + The test app creates the `admin` and `user` users in [BootStrap.groovy](test1/grails-app/init/grails/plugin/springsecurity/cas/test/BootStrap.groovy). The password is the same as the username, and the containerised CAS server is configured to accept diff --git a/grails-test-examples/spring-security/cas/test1/build.gradle b/grails-test-examples/spring-security/cas/test1/build.gradle index d91f3c09b1..4b5954db85 100644 --- a/grails-test-examples/spring-security/cas/test1/build.gradle +++ b/grails-test-examples/spring-security/cas/test1/build.gradle @@ -67,6 +67,18 @@ tasks.withType(Test).configureEach { } } +tasks.named('bootRun') { + // bootRun forks its own JVM, so the properties that select a CAS configuration have to be + // forwarded explicitly. Without TESTCONFIG the proxy settings are never configured and + // /secure/proxyStatus can only report that no proxy ticket is available. + ['TESTCONFIG', 'casContainerVersion'].each { String name -> + String value = System.getProperty(name) ?: project.findProperty(name) + if (value) { + systemProperty(name, value) + } + } +} + apply { from rootProject.layout.projectDirectory.file('gradle/functional-test-config.gradle') from rootProject.layout.projectDirectory.file('gradle/grails-extension-gradle-config.gradle') diff --git a/grails-test-examples/spring-security/cas/test1/grails-app/controllers/grails/plugin/springsecurity/cas/test/SecureController.groovy b/grails-test-examples/spring-security/cas/test1/grails-app/controllers/grails/plugin/springsecurity/cas/test/SecureController.groovy index 421a19cebe..db439ee8f0 100644 --- a/grails-test-examples/spring-security/cas/test1/grails-app/controllers/grails/plugin/springsecurity/cas/test/SecureController.groovy +++ b/grails-test-examples/spring-security/cas/test1/grails-app/controllers/grails/plugin/springsecurity/cas/test/SecureController.groovy @@ -27,32 +27,32 @@ import org.springframework.security.core.context.SecurityContextHolder class SecureController { - @Secured('ROLE_ADMIN') - def admins() { - render 'Logged in with ROLE_ADMIN' - } + @Secured('ROLE_ADMIN') + def admins() { + render 'Logged in with ROLE_ADMIN' + } - @Secured('ROLE_USER') - def users() { - render 'Logged in with ROLE_USER' - } + @Secured('ROLE_USER') + def users() { + render 'Logged in with ROLE_USER' + } - /** - * Asks CAS for a proxy ticket on behalf of the logged-in user. This only succeeds when the - * proxy receptor is configured, because CAS delivers the proxy-granting ticket by calling back - * to the receptor URL while the service ticket is being validated. - */ - @Secured('ROLE_USER') - def proxyStatus() { - Authentication authentication = SecurityContextHolder.context.authentication - if (!(authentication instanceof CasAuthenticationToken)) { - render 'NOT_A_CAS_AUTHENTICATION' - return - } + /** + * Asks CAS for a proxy ticket on behalf of the logged-in user. This only succeeds when the + * proxy receptor is configured, because CAS delivers the proxy-granting ticket by calling back + * to the receptor URL while the service ticket is being validated. + */ + @Secured('ROLE_USER') + def proxyStatus() { + Authentication authentication = SecurityContextHolder.context.authentication + if (!(authentication instanceof CasAuthenticationToken)) { + render 'NOT_A_CAS_AUTHENTICATION' + return + } - AttributePrincipal principal = ((CasAuthenticationToken) authentication).assertion.principal - String targetService = params.targetService ?: 'http://localhost/proxied-service' - String proxyTicket = principal.getProxyTicketFor(targetService) - render proxyTicket ? "PROXY_TICKET:${proxyTicket}" : 'NO_PROXY_TICKET' - } + AttributePrincipal principal = ((CasAuthenticationToken) authentication).assertion.principal + String targetService = params.targetService ?: 'http://localhost/proxied-service' + String proxyTicket = principal.getProxyTicketFor(targetService) + render proxyTicket ? "PROXY_TICKET:${proxyTicket}" : 'NO_PROXY_TICKET' + } } diff --git a/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/grails/plugin/springsecurity/cas/test/AbstractCasSpec.groovy b/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/grails/plugin/springsecurity/cas/test/AbstractCasSpec.groovy index 2dc654ffee..a8fbc7a9ca 100644 --- a/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/grails/plugin/springsecurity/cas/test/AbstractCasSpec.groovy +++ b/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/grails/plugin/springsecurity/cas/test/AbstractCasSpec.groovy @@ -86,13 +86,14 @@ abstract class AbstractCasSpec extends Specification { } HttpResponse<String> get(HttpClient client, String url) { - client.send(HttpRequest.newBuilder(URI.create(url)).GET().build(), + client.send(HttpRequest.newBuilder(URI.create(url)).timeout(REQUEST_TIMEOUT).GET().build(), HttpResponse.BodyHandlers.ofString()) } HttpResponse<String> postForm(HttpClient client, String url, Map<String, String> form) { String body = form.collect { k, v -> "${encode(k)}=${encode(v)}" }.join('&') HttpRequest request = HttpRequest.newBuilder(URI.create(url)) + .timeout(REQUEST_TIMEOUT) .header('Content-Type', 'application/x-www-form-urlencoded') .POST(HttpRequest.BodyPublishers.ofString(body)) .build() @@ -144,6 +145,12 @@ abstract class AbstractCasSpec extends Specification { local.startsWith('http') ? local : appBaseUrl + local } + /** + * A container that accepts the connection but never answers would otherwise block + * {@code send()} forever and hang the build until the CI runner kills the job. + */ + static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(60) + /** The message CAS sends to a service on back-channel logout. */ static String logoutRequest(String serviceTicket) { """<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" \ @@ -158,7 +165,7 @@ ID="LR-1-${System.nanoTime()}" Version="2.0" IssueInstant="2026-01-01T00:00:00Z" matcher.find() ? matcher.group(1) : null } - private static String extractExecution(String html) { + static String extractExecution(String html) { def matcher = html =~ /name="execution"\s+value="([^"]+)"/ matcher.find() ? matcher.group(1) : null } diff --git a/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/grails/plugin/springsecurity/cas/test/CasLoginSpec.groovy b/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/grails/plugin/springsecurity/cas/test/CasLoginSpec.groovy index 2d9cd362c4..259034579f 100644 --- a/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/grails/plugin/springsecurity/cas/test/CasLoginSpec.groovy +++ b/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/grails/plugin/springsecurity/cas/test/CasLoginSpec.groovy @@ -81,7 +81,8 @@ class CasLoginSpec extends AbstractCasSpec { HttpResponse<String> challenge = get(appClient, "${appBaseUrl}/secure/users") String loginUrl = location(challenge) HttpResponse<String> form = get(casClient, loginUrl) - String execution = form.body().find(/name="execution"\s+value="([^"]+)"/) { full, token -> token } + String execution = extractExecution(form.body()) + assert execution, 'CAS login form did not contain an execution token' HttpResponse<String> submitted = postForm(casClient, loginUrl, [username: 'user', password: 'wrong-password', execution: execution, _eventId: 'submit']) diff --git a/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/grails/plugin/springsecurity/cas/test/CasSingleSignOutSpec.groovy b/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/grails/plugin/springsecurity/cas/test/CasSingleSignOutSpec.groovy index 4614b3bc27..784e1f8698 100644 --- a/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/grails/plugin/springsecurity/cas/test/CasSingleSignOutSpec.groovy +++ b/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/grails/plugin/springsecurity/cas/test/CasSingleSignOutSpec.groovy @@ -24,9 +24,9 @@ import spock.lang.IgnoreIf import java.net.http.HttpResponse /** - * Covers single sign-out, which the plugin enables by default via {@code cas.useSingleSignout} by - * registering an {@code org.apereo.cas.client.session.SingleSignOutFilter} ahead of every other - * filter. + * Covers single sign-out, which the app opts into via {@code cas.useSingleSignout}. The plugin then + * registers an {@code org.apereo.cas.client.session.SingleSignOutFilter} ahead of every other + * filter. {@code CasNoSingleSignOutSpec} covers the default, where it is off. * * <p>The logout request is posted here rather than triggered from the CAS server. What belongs to * the plugin is <em>handling</em> the request - mapping the service ticket to the HTTP session and
