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

Reply via email to