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 06d8801b2c225103c27cff25c9ea3b65b9eacec2
Author: SΓΈren Berg Glasius <[email protected]>
AuthorDate: Wed Aug 19 17:40:25 2026 +0200

    fix(cas): repair proxy receptor and single sign-out, add integration tests
    
    The CAS plugin had no tests at all, and its only exerciser was a 
bootRun-only
    demo pointed at a hand-run CAS server. Two defects were living in that gap.
    
    Fixes:
    
    * casAuthenticationFilter set proxyReceptorUrl unconditionally. Spring 
Security
      7.1 rejects a null pattern, so an application that did not configure a 
proxy
      receptor - the default - failed to start. It is now only set when 
configured,
      which is how CasAuthenticationFilter expresses that proxy support is off.
      Previously the unguarded assignment produced a matcher for the literal 
path
      '/**null', quietly giving unconfigured applications a live proxy receptor.
    
    * Single sign-out never worked. The plugin set useSessionFixationPrevention 
to
      false from doWithSpring, but it declares loadAfter = 
['springSecurityCore'],
      so the core plugin had already defined sessionAuthenticationStrategy from 
the
      original value. The session was still replaced on login, so CAS logout
      requests matched no session. The bean is now redefined from the CAS 
plugin as
      well, through the same BeanTypeResolver the core plugin uses so an
      application's sessionAuthenticationStrategyBeanClass override still wins.
    
    Behaviour change:
    
    * cas.useSingleSignout now defaults to false. Enabling it disables session
      fixation prevention, which an application should choose deliberately 
rather
      than inherit. The plugin warns at startup when it is enabled. Documented 
in
      the CAS configuration reference and in the Grails 8 upgrade guide.
    
    Tests:
    
    grails-test-examples-spring-security-cas-test1 now runs against a real 
Apereo
    CAS server started with Testcontainers, gated on Docker availability, and no
    longer needs an externally managed CAS server for bootRun either. An
    EnvironmentPostProcessor starts the container and supplies the CAS URLs 
before
    the context is built; the service and proxy callback URLs, which depend on 
the
    port the embedded server binds, are set once the server is up.
    
    Three configurations run via the existing TESTCONFIG idiom: 'cas' (proxy
    settings unset), 'casProxy' (full proxy-granting-ticket round trip) and
    'casNoSingleSignout' (asserts the new default). Coverage spans the login
    handshake, ticket validation, role enforcement, proxy tickets obtained 
through
    AttributePrincipal, and single sign-out. Logout requests are posted from a
    cookie-less client, as CAS does, so the filter's behaviour cannot be 
confused
    with the session clearing that a failed authentication would cause anyway.
    
    Also fixes an unrelated pre-existing gap: 'Grails BOM Hibernate7 
Micronaut.adoc'
    is generated and gitignored but was missing from the rat exclusions, so rat
    failed after any docs build.
---
 .github/workflows/gradle.yml                       |  40 +++++
 gradle/rat-root-config.gradle                      |   2 +
 .../springSecurity/cas/configuration.adoc          |  26 ++-
 .../src/en/guide/upgrading/upgrading80x.adoc       |  33 ++++
 .../conf/DefaultCasSecurityConfig.groovy           |   5 +-
 .../cas/SpringSecurityCasGrailsPlugin.groovy       |  34 +++-
 grails-test-examples/spring-security/cas/README.md |  70 +++++++-
 .../spring-security/cas/test1/build.gradle         |  13 ++
 .../cas/test1/grails-app/conf/application.groovy   |  12 +-
 .../spring/resources.groovy}                       |  17 +-
 .../spring/security/cas/SecureController.groovy    |  23 +++
 .../groovy/specs/AbstractCasSpec.groovy            | 182 +++++++++++++++++++++
 .../groovy/specs/CasLoginSpec.groovy               |  90 ++++++++++
 .../groovy/specs/CasNoProxyReceptorSpec.groovy     |  82 ++++++++++
 .../groovy/specs/CasNoSingleSignOutSpec.groovy     |  59 +++++++
 .../groovy/specs/CasProxyTicketSpec.groovy         |  64 ++++++++
 .../groovy/specs/CasSingleSignOutSpec.groovy       |  75 +++++++++
 .../security/cas/test/CasContainerHolder.groovy    | 111 +++++++++++++
 .../cas/test/CasServiceUrlConfigurer.groovy        |  57 +++++++
 .../spring/security/cas/test/CasTestConfig.groovy  |  72 ++++++++
 .../test/CasTestEnvironmentPostProcessor.groovy    |  62 +++++++
 .../src/main/resources/META-INF/spring.factories   |  19 +++
 .../cas/services/grailsTest-10000001.json          |  12 ++
 23 files changed, 1126 insertions(+), 34 deletions(-)

diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml
index 39f808c509..7d547f8ebc 100644
--- a/.github/workflows/gradle.yml
+++ b/.github/workflows/gradle.yml
@@ -567,6 +567,46 @@ jobs:
           --stacktrace
           -DTESTCONFIG=${{ matrix.test-config }}
           -PskipCodeStyle
+  casSecurityConfig:
+    name: "Spring Security CAS Functional Tests"
+    if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }}
+    strategy:
+      fail-fast: false
+      matrix:
+        # The CAS test app authenticates against a real Apereo CAS server 
started via Testcontainers.
+        # 'cas' leaves the proxy settings unset and is the default, so it is 
also covered by the main
+        # security job; 'casProxy' exercises the proxy-granting-ticket 
callback; 'casNoSingleSignout'
+        # asserts the shipped default, where single signout is not enabled.
+        # TESTCONFIG is applied at app startup, so every config must be its 
own run.
+        # Carried over from the standalone grails-spring-security CI 
convention - add, don't remove.
+        test-config: [ 'cas', 'casProxy', 'casNoSingleSignout' ]
+    runs-on: ubuntu-24.04
+    steps:
+      - name: "Output Agent IP" # in the event RAO blocks this agent, this can 
be used to debug it
+        run: curl -s https://api.ipify.org
+      - name: "πŸ“₯ Checkout repository"
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 
v6.0.2
+      - name: "β˜•οΈ Setup JDK"
+        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # 
v5.2.0
+        with:
+          distribution: liberica
+          java-version: 21
+      - name: "🐘 Setup Gradle"
+        uses: 
gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+        with:
+          cache-provider: basic # 'basic' uses the MIT-licensed, open-source 
cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle 
commercial Terms of Use)
+          develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY  }}
+      - name: "πŸ” Setup TestLens"
+        uses: 
testlens-app/setup-testlens@3f82d2dc6cd5c03f02ce5f7885a21195d85f8d14 # v1.9.3
+      - name: "πŸƒ Run Spring Security CAS Functional Tests (TESTCONFIG=${{ 
matrix.test-config }})"
+        run: >
+          ./gradlew
+          :grails-test-examples-spring-security-cas-test1:check
+          --continue
+          --rerun-tasks
+          --stacktrace
+          -DTESTCONFIG=${{ matrix.test-config }}
+          -PskipCodeStyle
   redis:
     name: "Redis Tests"
     if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }}
diff --git a/gradle/rat-root-config.gradle b/gradle/rat-root-config.gradle
index 99153d107b..308bf16d0f 100644
--- a/gradle/rat-root-config.gradle
+++ b/gradle/rat-root-config.gradle
@@ -58,6 +58,7 @@ tasks.named('rat') {
             'grails-doc/src/en/ref/Versions/Grails BOM Hibernate5.adoc', // 
exclude generated data
             'grails-doc/src/en/ref/Versions/Grails BOM Hibernate7.adoc', // 
exclude generated data
             'grails-doc/src/en/ref/Versions/Grails BOM Micronaut.adoc', // 
exclude generated data
+            'grails-doc/src/en/ref/Versions/Grails BOM Hibernate7 
Micronaut.adoc', // exclude generated data
             'grails-profiles/**/templates/**', // template files that people 
are expected to use in the end application
             'grails-profiles/**/commands/**', // template files that people 
are expected to use in the end application
             'grails-profiles/**/features/**', // template files that people 
are expected to use in the end application
@@ -149,6 +150,7 @@ tasks.named('rat') {
             '**/*.log', // exclude log files
             'local-tasks.gradle', // exclude local helper scripts
             '**/spring-configuration-metadata.json', // JSON files cannot 
contain license headers
+            
'grails-test-examples/spring-security/cas/test1/src/main/resources/cas/services/*.json',
 // CAS service registry definitions; JSON files cannot contain license headers
             'grails-benchmarks/src/reportTest/resources/jmh-golden/**', // 
byte-exact JMH report fixtures; a license header would alter the compared bytes
     ] + 
rootProject.subprojects.collect{"${rootProject.projectDir.relativePath(it.layout.buildDirectory.get().asFile).toString()}/**/*"
 }
     // logger.lifecycle("Excludes for RAT task: ${allExcludes.join(', \n')}")
diff --git 
a/grails-doc/src/en/guide/security/securityPlugins/springSecurity/cas/configuration.adoc
 
b/grails-doc/src/en/guide/security/securityPlugins/springSecurity/cas/configuration.adoc
index 31251bdcb9..209a6373b5 100644
--- 
a/grails-doc/src/en/guide/security/securityPlugins/springSecurity/cas/configuration.adoc
+++ 
b/grails-doc/src/en/guide/security/securityPlugins/springSecurity/cas/configuration.adoc
@@ -49,7 +49,27 @@ grails:
 | cas.artifactParameter | `'ticket'` | the ticket login url parameter
 | cas.serviceParameter | `'service'` | the service login url parameter
 | cas.filterProcessesUrl | '/login/cas' | the URL that the filter intercepts 
for login
-| cas.proxyCallbackUrl | `null`, should be set | proxy callback url, e.g. 
'http://localhost:8080/secure/receptor'
-| cas.proxyReceptorUrl | `null`, should be set | proxy receptor url, e.g. 
'/secure/receptor'
-| cas.useSingleSignout | `true` | if `true` a `org.jasig.cas.client.session. 
SingleSignOutFilter` is registered
+| cas.proxyCallbackUrl | `null` | proxy callback url, e.g. 
'http://localhost:8080/secure/receptor'. Only needed for proxy tickets, and 
only meaningful together with `cas.proxyReceptorUrl`
+| cas.proxyReceptorUrl | `null` | proxy receptor url, e.g. '/secure/receptor'. 
Only needed for proxy tickets, and only meaningful together with 
`cas.proxyCallbackUrl`. When unset, no request is treated as a proxy receptor 
request
+| cas.useSingleSignout | `false` | if `true` a `org.apereo.cas.client.session. 
SingleSignOutFilter` is registered, and session fixation prevention is disabled 
(see below)
 |====================
+
+[WARNING]
+====
+Single sign-out and session fixation prevention cannot both be active. CAS 
maps the service ticket
+to the HTTP session id, so if the session is replaced when the user 
authenticates, the session CAS
+later asks the application to invalidate no longer exists and the logout 
request has no effect.
+
+`cas.useSingleSignout` is therefore opt-in. Enabling it makes the plugin set
+`grails.plugin.springsecurity.useSessionFixationPrevention` to `false` and 
define
+`sessionAuthenticationStrategy` accordingly, overriding whatever the core 
plugin configured, and log
+a warning at startup so the trade-off is visible:
+
+[source,groovy]
+.grails-app/conf/application.groovy
+----
+grails.plugin.springsecurity.cas.useSingleSignout = true
+----
+
+Leave it off to keep session fixation prevention and handle logout in the 
application instead.
+====
diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc 
b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
index 4556cab7f9..40257a4766 100644
--- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
+++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
@@ -2361,3 +2361,36 @@ resolved unambiguously before can become ambiguous.
 
 Removing the packages from `grails.spring.bean.packages`, or the stray classes 
from those packages, restores
 the previous set of beans.
+
+==== 45. 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
+it wanted single sign-out.
+
+The default changed because enabling single sign-out is a security trade-off 
rather than a free
+feature. CAS maps the service ticket to the HTTP session id, so single 
sign-out cannot work while
+session fixation prevention is replacing the session when the user 
authenticates. Enabling
+`cas.useSingleSignout` disables session fixation prevention, and an 
application should make that
+choice deliberately.
+
+If your application relies on CAS single sign-out, enable it explicitly:
+
+[source,groovy]
+.grails-app/conf/application.groovy
+----
+grails.plugin.springsecurity.cas.useSingleSignout = true
+----
+
+The plugin logs a warning at startup when it is enabled, noting that session 
fixation prevention has
+been disabled.
+
+**Single sign-out did not actually work before Grails 8**, so an application 
upgrading from an
+earlier version is unlikely to lose working behaviour. The plugin set
+`useSessionFixationPrevention = false` from `doWithSpring`, but it declares 
`loadAfter =
+['springSecurityCore']`, so the core plugin had already defined 
`sessionAuthenticationStrategy` from
+the original value. The filter was registered, the session was still replaced 
on login, and CAS
+logout requests silently matched nothing. Grails 8 also redefines 
`sessionAuthenticationStrategy`
+from the CAS plugin, so enabling `cas.useSingleSignout` now has the documented 
effect.
+
+The Spring Security CAS configuration reference in this guide documents the 
setting in full.
diff --git 
a/grails-spring-security/cas/plugin/grails-app/conf/DefaultCasSecurityConfig.groovy
 
b/grails-spring-security/cas/plugin/grails-app/conf/DefaultCasSecurityConfig.groovy
index f13552e180..a135acda79 100644
--- 
a/grails-spring-security/cas/plugin/grails-app/conf/DefaultCasSecurityConfig.groovy
+++ 
b/grails-spring-security/cas/plugin/grails-app/conf/DefaultCasSecurityConfig.groovy
@@ -30,6 +30,9 @@ security {
         filterProcessesUrl = '/login/cas'
         proxyCallbackUrl = null // should be set, e.g. 
'http://localhost:8080/myapp/secure/receptor'
         proxyReceptorUrl = null // should be set, e.g. '/secure/receptor'
-        useSingleSignout = true
+        // Opt-in: enabling it disables session fixation prevention, because 
CAS maps the
+        // service ticket to the session id and single signout cannot work if 
the session
+        // is replaced when the user authenticates.
+        useSingleSignout = false
     }
 }
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 041f72e200..ffba5ff7ed 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
@@ -20,6 +20,7 @@ package grails.plugin.springsecurity.cas
 
 import groovy.transform.CompileDynamic
 import groovy.transform.CompileStatic
+import groovy.util.logging.Slf4j
 
 import org.apereo.cas.client.proxy.Cas20ProxyRetriever
 import org.apereo.cas.client.proxy.ProxyGrantingTicketStorageImpl
@@ -35,11 +36,14 @@ import 
org.springframework.security.cas.authentication.CasAuthenticationProvider
 import org.springframework.security.cas.authentication.NullStatelessTicketCache
 import org.springframework.security.cas.web.CasAuthenticationEntryPoint
 import org.springframework.security.cas.web.CasAuthenticationFilter
+import 
org.springframework.security.web.authentication.session.NullAuthenticatedSessionStrategy
 
+import grails.plugin.springsecurity.BeanTypeResolver
 import grails.plugin.springsecurity.SecurityFilterPosition
 import grails.plugin.springsecurity.SpringSecurityUtils
 import grails.plugins.Plugin
 
+@Slf4j
 @CompileStatic
 class SpringSecurityCasGrailsPlugin extends Plugin {
 
@@ -87,10 +91,30 @@ class SpringSecurityCasGrailsPlugin extends Plugin {
 
             if (conf.cas.useSingleSignout) {
 
-                // session fixation prevention breaks single signout because
-                // the service ticket is mapped to the session id which changes
+                // Session fixation prevention breaks single signout because 
the service ticket is
+                // mapped to the session id, which changes when the session is 
replaced on login.
+                // Disabling it is a security trade-off the application has 
opted into, so say so.
+                String message = '''
+    WARNING: cas.useSingleSignout is enabled, so session fixation prevention 
has been disabled.
+    CAS maps the service ticket to the HTTP session id, and a logout request 
cannot be matched to a
+    session that was replaced when the user authenticated. Set 
cas.useSingleSignout to false to keep
+    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
+                // the core plugin defines it when the setting is off. The 
value is still updated so
+                // that anything reading the config later sees what is 
actually in effect.
                 conf.useSessionFixationPrevention = false
 
+                Class beanTypeResolverClass = (conf.beanTypeResolverClass ?: 
BeanTypeResolver) as Class
+                def casBeanTypeResolver = 
beanTypeResolverClass.newInstance(conf, grailsApplication)
+                sessionAuthenticationStrategy(casBeanTypeResolver.resolveType(
+                        'sessionAuthenticationStrategy', 
NullAuthenticatedSessionStrategy))
+
                 singleSignOutFilter(SingleSignOutFilter) {
                     ignoreInitConfiguration = true
                 }
@@ -135,7 +159,11 @@ class SpringSecurityCasGrailsPlugin extends Plugin {
                 continueChainBeforeSuccessfulAuthentication = 
conf.apf.continueChainBeforeSuccessfulAuthentication
                 // false
                 allowSessionCreation = conf.apf.allowSessionCreation // true
-                proxyReceptorUrl = conf.cas.proxyReceptorUrl
+                // Only set when configured: CasAuthenticationFilter rejects a 
null pattern, and an
+                // unset receptor is how the filter expresses that proxy 
support is disabled.
+                if (conf.cas.proxyReceptorUrl) {
+                    proxyReceptorUrl = conf.cas.proxyReceptorUrl
+                }
             }
 
             casProxyRetriever(Cas20ProxyRetriever, conf.cas.serverUrlPrefix, 
conf.cas.serverUrlEncoding /*'UTF-8'*/)
diff --git a/grails-test-examples/spring-security/cas/README.md 
b/grails-test-examples/spring-security/cas/README.md
index 6e9f9a9785..cacf7a812b 100644
--- a/grails-test-examples/spring-security/cas/README.md
+++ b/grails-test-examples/spring-security/cas/README.md
@@ -16,19 +16,73 @@ limitations under the License.
 
 This is a CAS-enabled test application.  To run it successfully, a CAS
 server is required.  The URL for the CAS server is configured in the
-[application.groovy](test1/grails-app/conf/application.groovy)
-file.  Setting up a CAS server is out of the scope of this document, but
-good places to start are [Apereo CAS GitHub](https://github.com/apereo/cas)
-and the [CAS Initializr](https://getcas.apereo.org/ui) service.
+This is a CAS-enabled test application. It no longer needs a hand-run CAS 
server: an
+[Apereo CAS](https://github.com/apereo/cas) server is started in a container by
+[CasContainerHolder](test1/src/main/groovy/spring/security/cas/test/CasContainerHolder.groovy),
+and 
[CasTestEnvironmentPostProcessor](test1/src/main/groovy/spring/security/cas/test/CasTestEnvironmentPostProcessor.groovy)
+points the CAS plugin at it before the application context is built. Docker 
(or a compatible
+container runtime) is therefore required to run or test this application.
 
-The test application can be run with:
+## Running the tests
 
-`./gradlew :testapp-spring-security-cas-test1:bootRun`
+The application is exercised under two configurations, selected with the 
`TESTCONFIG` system
+property. Each has to be its own run, because the configuration is applied at 
application startup.
+
+| `TESTCONFIG` | Configuration | Covered by |
+|---|---|---|
+| `cas` (default) | `proxyCallbackUrl` and `proxyReceptorUrl` unset, single 
signout enabled | `CasLoginSpec`, `CasNoProxyReceptorSpec`, 
`CasSingleSignOutSpec` |
+| `casProxy` | both proxy settings configured, single signout enabled | 
`CasLoginSpec`, `CasProxyTicketSpec`, `CasSingleSignOutSpec` |
+| `casNoSingleSignout` | `cas.useSingleSignout` left at its default | 
`CasLoginSpec`, `CasNoProxyReceptorSpec`, `CasNoSingleSignOutSpec` |
+
+```
+./gradlew :grails-test-examples-spring-security-cas-test1:check 
-DTESTCONFIG=cas
+./gradlew :grails-test-examples-spring-security-cas-test1:check 
-DTESTCONFIG=casProxy
+./gradlew :grails-test-examples-spring-security-cas-test1:check 
-DTESTCONFIG=casNoSingleSignout
+```
+
+`cas.useSingleSignout` is opt-in as of Grails 8. The app enables it for the 
first two configurations
+so the single signout filter is exercised; enabling it disables session 
fixation prevention, and the
+plugin warns about that at startup.
+
+The CAS image is pinned to a known-good version and can be overridden:
+
+```
+./gradlew :grails-test-examples-spring-security-cas-test1:check 
-PcasContainerVersion=7.3.6
+```
+
+The specs skip themselves when no Docker daemon is available.
+
+## Running the application
+
+```
+./gradlew :grails-test-examples-spring-security-cas-test1:bootRun
+```
 
 The test application URLs are:
 * [http://localhost:8081/secure/admins](http://localhost:8081/secure/admins)
 * [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
 
 The test app creates the `admin` and `user` users in
-[BootStrap.groovy](test1/grails-app/init/spring/security/cas/test/BootStrap.groovy).
 
-The password is the same as the username.
+[BootStrap.groovy](test1/grails-app/init/spring/security/cas/test/BootStrap.groovy).
+The password is the same as the username, and the containerised CAS server is 
configured to accept
+the same two accounts.
+
+## How the container is reached
+
+The CAS server runs in a container while the application runs on the host, so 
the two see each other
+at different addresses:
+
+| Leg | Address used |
+|---|---|
+| application and browser β†’ CAS | `http://localhost:<mapped port>/cas` |
+| CAS β†’ application (single logout, proxy callback) | 
`http://host.testcontainers.internal:<server port>/...` |
+
+The service URL and the proxy callback URL depend on the port the embedded 
server binds, which is
+random under integration tests. They are set by
+[CasServiceUrlConfigurer](test1/src/main/groovy/spring/security/cas/test/CasServiceUrlConfigurer.groovy)
+once the server has started but before it serves a request.
+
+CAS only authorises `https` services out of the box, so a service definition 
permitting `http` β€” and
+carrying the proxy policy that lets CAS issue proxy-granting tickets β€” is 
copied into the container
+from 
[grailsTest-10000001.json](test1/src/main/resources/cas/services/grailsTest-10000001.json).
diff --git a/grails-test-examples/spring-security/cas/test1/build.gradle 
b/grails-test-examples/spring-security/cas/test1/build.gradle
index df91651532..d91f3c09b1 100644
--- a/grails-test-examples/spring-security/cas/test1/build.gradle
+++ b/grails-test-examples/spring-security/cas/test1/build.gradle
@@ -41,6 +41,9 @@ dependencies {
     implementation 'org.webjars:bootstrap:3.3.6'
     implementation 'org.webjars:jquery:2.2.0'
 
+    // Starts the CAS server the app authenticates against; see 
CasContainerHolder
+    implementation 'org.testcontainers:testcontainers'
+
     compileOnly 'org.slf4j:slf4j-nop' // Prevents warning about missing slf4j 
implementation during compilation
 
     runtimeOnly 'cloud.wondrify:asset-pipeline-grails'
@@ -52,6 +55,16 @@ dependencies {
     runtimeOnly 'org.springframework.boot:spring-boot-autoconfigure'
     runtimeOnly 'org.springframework.boot:spring-boot-starter-logging'
     runtimeOnly 'org.springframework.boot:spring-boot-starter-tomcat'
+
+    integrationTestImplementation 
'org.apache.grails:grails-testing-support-web'
+    integrationTestImplementation 'org.spockframework:spock-core'
+}
+
+tasks.withType(Test).configureEach {
+    // Lets CI pin or matrix the CAS server version, mirroring 
-PredisContainerVersion
+    if (project.hasProperty('casContainerVersion')) {
+        systemProperty('casContainerVersion', 
project.property('casContainerVersion'))
+    }
 }
 
 apply {
diff --git 
a/grails-test-examples/spring-security/cas/test1/grails-app/conf/application.groovy
 
b/grails-test-examples/spring-security/cas/test1/grails-app/conf/application.groovy
index 33114cffb3..49e374b560 100644
--- 
a/grails-test-examples/spring-security/cas/test1/grails-app/conf/application.groovy
+++ 
b/grails-test-examples/spring-security/cas/test1/grails-app/conf/application.groovy
@@ -22,11 +22,13 @@ grails {
                springsecurity {
                        authority.className = 'com.test.Role'
          cas {
-            loginUri         = '/login'
-            serverUrlPrefix  = 'http://localhost:9090/cas'
-            proxyReceptorUrl = '/secure/receptor'
-            serviceUrl       = 'http://localhost:${server.port}/login/cas'
-            proxyCallbackUrl = 
'http://localhost:${server.port}/secure/receptor'
+            // serverUrlPrefix, serviceUrl and (for TESTCONFIG=casProxy) 
proxyCallbackUrl /
+            // proxyReceptorUrl are supplied by 
CasTestEnvironmentPostProcessor, which starts the
+            // CAS server in a container and derives them from the ports 
actually in use.
+            loginUri = '/login'
+            // Opt in everywhere except the config that asserts the shipped 
default is off.
+            // Enabling it disables session fixation prevention; the plugin 
warns at startup.
+            useSingleSignout = System.getProperty('TESTCONFIG') != 
'casNoSingleSignout'
          }
                        controllerAnnotations.staticRules = [
                                [pattern: '/',               access: 
'permitAll'],
diff --git 
a/grails-test-examples/spring-security/cas/test1/grails-app/controllers/spring/security/cas/SecureController.groovy
 
b/grails-test-examples/spring-security/cas/test1/grails-app/conf/spring/resources.groovy
similarity index 75%
copy from 
grails-test-examples/spring-security/cas/test1/grails-app/controllers/spring/security/cas/SecureController.groovy
copy to 
grails-test-examples/spring-security/cas/test1/grails-app/conf/spring/resources.groovy
index 911d9aa42e..488a5bbbf2 100644
--- 
a/grails-test-examples/spring-security/cas/test1/grails-app/controllers/spring/security/cas/SecureController.groovy
+++ 
b/grails-test-examples/spring-security/cas/test1/grails-app/conf/spring/resources.groovy
@@ -17,19 +17,8 @@
  *  under the License.
  */
 
-package spring.security.cas
+import spring.security.cas.test.CasServiceUrlConfigurer
 
-import grails.plugin.springsecurity.annotation.Secured
-
-class SecureController {
-
-       @Secured('ROLE_ADMIN')
-       def admins() {
-               render 'Logged in with ROLE_ADMIN'
-       }
-
-       @Secured('ROLE_USER')
-       def users() {
-               render 'Logged in with ROLE_USER'
-       }
+beans = {
+    casServiceUrlConfigurer(CasServiceUrlConfigurer)
 }
diff --git 
a/grails-test-examples/spring-security/cas/test1/grails-app/controllers/spring/security/cas/SecureController.groovy
 
b/grails-test-examples/spring-security/cas/test1/grails-app/controllers/spring/security/cas/SecureController.groovy
index 911d9aa42e..e073d68c46 100644
--- 
a/grails-test-examples/spring-security/cas/test1/grails-app/controllers/spring/security/cas/SecureController.groovy
+++ 
b/grails-test-examples/spring-security/cas/test1/grails-app/controllers/spring/security/cas/SecureController.groovy
@@ -20,6 +20,10 @@
 package spring.security.cas
 
 import grails.plugin.springsecurity.annotation.Secured
+import org.apereo.cas.client.authentication.AttributePrincipal
+import org.springframework.security.cas.authentication.CasAuthenticationToken
+import org.springframework.security.core.Authentication
+import org.springframework.security.core.context.SecurityContextHolder
 
 class SecureController {
 
@@ -32,4 +36,23 @@ class SecureController {
        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
+               }
+
+               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/specs/AbstractCasSpec.groovy
 
b/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/specs/AbstractCasSpec.groovy
new file mode 100644
index 0000000000..703b088403
--- /dev/null
+++ 
b/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/specs/AbstractCasSpec.groovy
@@ -0,0 +1,182 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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
+ *
+ *    https://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 specs
+
+import grails.testing.mixin.integration.Integration
+import spock.lang.Requires
+import spock.lang.Specification
+import spring.security.cas.test.CasContainerHolder
+import spring.security.cas.test.CasTestConfig
+
+import java.net.CookieManager
+import java.net.CookiePolicy
+import java.net.http.HttpClient
+import java.net.http.HttpRequest
+import java.net.http.HttpResponse
+import java.time.Duration
+
+/**
+ * Drives the CAS protocol by hand against the containerised CAS server.
+ *
+ * <p>The app and CAS are both reached on {@code localhost}, and cookies 
ignore ports, so a single
+ * cookie jar would send the app's session cookie to CAS and vice versa. Each 
side therefore gets
+ * its own client, and redirects are followed explicitly so the right one is 
used for each hop.</p>
+ */
+@Integration
+@Requires({ isDockerAvailable() })
+abstract class AbstractCasSpec extends Specification {
+
+    HttpClient appClient
+    HttpClient casClient
+
+    /**
+     * A cookie-less client for the calls CAS makes to the application on its 
own connection, such
+     * as the back-channel logout request. Using the authenticated client 
instead would hide which
+     * filter acted: an unconsumed POST to the CAS login path fails 
authentication and clears the
+     * session by itself, which looks the same from outside as single signout 
working.
+     */
+    HttpClient backChannelClient
+
+    /** The service ticket CAS issued during the most recent {@link #login} 
call. */
+    String lastServiceTicket
+
+    void setup() {
+        appClient = newClient()
+        casClient = newClient()
+        backChannelClient = newClient()
+    }
+
+    /**
+     * Mirrors the probe used by the hibernate7 specs. Checking the socket 
avoids the macOS failure
+     * mode where asking Testcontainers for a client throws when the daemon 
API version differs.
+     */
+    static boolean isDockerAvailable() {
+        List<String> candidates = [
+                System.getProperty('user.home') + '/.docker/run/docker.sock',
+                '/var/run/docker.sock',
+                System.getenv('DOCKER_HOST') ?: ''
+        ]
+        candidates.any { it && new File(it).exists() }
+    }
+
+    String getAppBaseUrl() {
+        "http://localhost:${serverPort}";
+    }
+
+    String getCasBaseUrl() {
+        CasContainerHolder.serverUrlPrefix
+    }
+
+    /** CAS redirects to the container-visible host name; the test client has 
to use localhost. */
+    static String toLocalUrl(String url) {
+        url.replace(CasTestConfig.CONTAINER_VISIBLE_HOST, 'localhost')
+    }
+
+    HttpResponse<String> get(HttpClient client, String url) {
+        client.send(HttpRequest.newBuilder(URI.create(url)).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))
+                .header('Content-Type', 'application/x-www-form-urlencoded')
+                .POST(HttpRequest.BodyPublishers.ofString(body))
+                .build()
+        client.send(request, HttpResponse.BodyHandlers.ofString())
+    }
+
+    static String location(HttpResponse<?> response) {
+        response.headers().firstValue('Location').orElse(null)
+    }
+
+    /**
+     * Authenticates at CAS and returns the service ticket URL it redirects 
back to, already
+     * rewritten to localhost.
+     */
+    String authenticateAtCas(String loginUrl, String username, String 
password) {
+        HttpResponse<String> form = get(casClient, loginUrl)
+        assert form.statusCode() == 200
+        String execution = extractExecution(form.body())
+        assert execution, 'CAS login form did not contain an execution token'
+
+        HttpResponse<String> submitted = postForm(casClient, loginUrl,
+                [username: username, password: password, execution: execution, 
_eventId: 'submit'])
+        assert submitted.statusCode() == 302,
+                "expected CAS to redirect after login but got 
${submitted.statusCode()}"
+        toLocalUrl(location(submitted))
+    }
+
+    /** Full login: hit a secured URL, authenticate at CAS, and follow the 
ticket back to the app. */
+    HttpResponse<String> login(String securedPath, String username, String 
password) {
+        HttpResponse<String> challenge = get(appClient, appBaseUrl + 
securedPath)
+        assert challenge.statusCode() == 302,
+                "expected a redirect to CAS but got ${challenge.statusCode()}"
+        String ticketUrl = authenticateAtCas(location(challenge), username, 
password)
+        lastServiceTicket = extractTicket(ticketUrl)
+        assert lastServiceTicket, "CAS redirect carried no service ticket: 
${ticketUrl}"
+        followRedirects(get(appClient, ticketUrl))
+    }
+
+    HttpResponse<String> followRedirects(HttpResponse<String> response, int 
limit = 5) {
+        HttpResponse<String> current = response
+        for (int i = 0; i < limit && current.statusCode() in [301, 302, 303, 
307, 308]; i++) {
+            current = get(appClient, absolute(location(current)))
+        }
+        current
+    }
+
+    String absolute(String location) {
+        String local = toLocalUrl(location)
+        local.startsWith('http') ? local : appBaseUrl + local
+    }
+
+    /** 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" \
+ID="LR-1-${System.nanoTime()}" Version="2.0" 
IssueInstant="2026-01-01T00:00:00Z">\
+<saml:NameID 
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">@NOT_USED@</saml:NameID>\
+<samlp:SessionIndex>${serviceTicket}</samlp:SessionIndex>\
+</samlp:LogoutRequest>"""
+    }
+
+    static String extractTicket(String url) {
+        def matcher = url =~ /[?&]ticket=([^&]+)/
+        matcher.find() ? matcher.group(1) : null
+    }
+
+    private static String extractExecution(String html) {
+        def matcher = html =~ /name="execution"\s+value="([^"]+)"/
+        matcher.find() ? matcher.group(1) : null
+    }
+
+    private static String encode(String value) {
+        URLEncoder.encode(value, 'UTF-8')
+    }
+
+    private static HttpClient newClient() {
+        CookieManager cookieManager = new CookieManager(null, 
CookiePolicy.ACCEPT_ALL)
+        HttpClient.newBuilder()
+                .cookieHandler(cookieManager)
+                .followRedirects(HttpClient.Redirect.NEVER)
+                .connectTimeout(Duration.ofSeconds(30))
+                .build()
+    }
+}
diff --git 
a/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/specs/CasLoginSpec.groovy
 
b/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/specs/CasLoginSpec.groovy
new file mode 100644
index 0000000000..82c2939783
--- /dev/null
+++ 
b/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/specs/CasLoginSpec.groovy
@@ -0,0 +1,90 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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
+ *
+ *    https://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 specs
+
+import spring.security.cas.test.CasTestConfig
+
+import java.net.http.HttpResponse
+
+/**
+ * Covers the CAS handshake the plugin exists to perform: redirect to CAS, 
service ticket back,
+ * ticket validated against the CAS server, and the resulting authentication 
carrying the roles
+ * looked up in GORM.
+ */
+class CasLoginSpec extends AbstractCasSpec {
+
+    void 'an unauthenticated request is redirected to the CAS login page for 
this service'() {
+        when:
+        HttpResponse<String> response = get(appClient, 
"${appBaseUrl}/secure/users")
+
+        then:
+        response.statusCode() == 302
+
+        and: 'the redirect targets the CAS server the container is running'
+        location(response).startsWith("${casBaseUrl}/login")
+
+        and: 'it asks CAS to send the ticket back to this application'
+        location(response).contains('service=')
+        
location(response).contains(URLEncoder.encode(CasTestConfig.serviceUrl(serverPort),
 'UTF-8'))
+    }
+
+    void 'a user authenticated at CAS reaches a ROLE_USER action'() {
+        when:
+        HttpResponse<String> response = login('/secure/users', 'user', 'user')
+
+        then:
+        response.statusCode() == 200
+        response.body().contains('Logged in with ROLE_USER')
+    }
+
+    void 'an admin authenticated at CAS reaches a ROLE_ADMIN action'() {
+        when:
+        HttpResponse<String> response = login('/secure/admins', 'admin', 
'admin')
+
+        then:
+        response.statusCode() == 200
+        response.body().contains('Logged in with ROLE_ADMIN')
+    }
+
+    void 'a user without the role is denied a ROLE_ADMIN action'() {
+        given:
+        login('/secure/users', 'user', 'user')
+
+        when:
+        HttpResponse<String> response = followRedirects(get(appClient, 
"${appBaseUrl}/secure/admins"))
+
+        then: 'access is refused rather than granted'
+        response.statusCode() == 403 || !response.body().contains('Logged in 
with ROLE_ADMIN')
+    }
+
+    void 'bad credentials do not authenticate'() {
+        when:
+        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 }
+        HttpResponse<String> submitted = postForm(casClient, loginUrl,
+                [username: 'user', password: 'wrong-password', execution: 
execution, _eventId: 'submit'])
+
+        then: 'CAS re-renders the login form instead of issuing a ticket'
+        submitted.statusCode() == 401 || submitted.statusCode() == 200
+        !submitted.headers().firstValue('Location').isPresent()
+    }
+}
diff --git 
a/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/specs/CasNoProxyReceptorSpec.groovy
 
b/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/specs/CasNoProxyReceptorSpec.groovy
new file mode 100644
index 0000000000..34883ccc42
--- /dev/null
+++ 
b/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/specs/CasNoProxyReceptorSpec.groovy
@@ -0,0 +1,82 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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
+ *
+ *    https://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 specs
+
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.context.ApplicationContext
+import org.springframework.security.cas.web.CasAuthenticationFilter
+import spock.lang.IgnoreIf
+import spring.security.cas.test.CasTestConfig
+
+import java.net.http.HttpResponse
+
+/**
+ * Regression coverage for leaving {@code cas.proxyReceptorUrl} unset, which 
is the default.
+ *
+ * <p>The plugin used to assign it unconditionally. Under Spring Security 7.1 
that fails outright
+ * because the request matcher rejects a null pattern, so this spec cannot 
even reach its first
+ * feature method without the guard - the application context does not start. 
Before that it
+ * produced a matcher for the literal path {@code /**null}, which quietly made 
an unconfigured app
+ * serve a live proxy receptor.</p>
+ */
+@IgnoreIf({ CasTestConfig.proxyEnabled })
+class CasNoProxyReceptorSpec extends AbstractCasSpec {
+
+    @Autowired
+    ApplicationContext applicationContext
+
+    void 'the application starts and wires the CAS filter with no proxy 
receptor configured'() {
+        expect: 'reaching this point at all means the context started'
+        applicationContext.getBean('casAuthenticationFilter', 
CasAuthenticationFilter)
+    }
+
+    void 'the receptor path is left to the application when no proxy receptor 
is configured'() {
+        given: 'an authenticated session, so the response cannot simply be a 
login redirect'
+        login('/secure/users', 'user', 'user')
+
+        when:
+        HttpResponse<String> response = followRedirects(
+                get(appClient, appBaseUrl + CasTestConfig.PROXY_RECEPTOR_URL))
+
+        then: 'the request reaches the application instead of being swallowed 
by the CAS filter'
+        response.statusCode() == 404
+    }
+
+    void 'a normal CAS login still works without proxy settings'() {
+        when:
+        HttpResponse<String> response = login('/secure/users', 'user', 'user')
+
+        then:
+        response.statusCode() == 200
+        response.body().contains('Logged in with ROLE_USER')
+    }
+
+    void 'no proxy ticket can be obtained when the receptor is not 
configured'() {
+        given:
+        login('/secure/users', 'user', 'user')
+
+        when:
+        HttpResponse<String> response = followRedirects(get(appClient, 
"${appBaseUrl}/secure/proxyStatus"))
+
+        then:
+        response.statusCode() == 200
+        response.body().trim() == 'NO_PROXY_TICKET'
+    }
+}
diff --git 
a/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/specs/CasNoSingleSignOutSpec.groovy
 
b/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/specs/CasNoSingleSignOutSpec.groovy
new file mode 100644
index 0000000000..32415e9a56
--- /dev/null
+++ 
b/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/specs/CasNoSingleSignOutSpec.groovy
@@ -0,0 +1,59 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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
+ *
+ *    https://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 specs
+
+import spock.lang.IgnoreIf
+import spring.security.cas.test.CasTestConfig
+
+import java.net.http.HttpResponse
+
+/**
+ * Asserts the shipped default: {@code cas.useSingleSignout} is off unless the 
application opts in.
+ *
+ * <p>Enabling it disables session fixation prevention, so it is a security 
trade-off rather than
+ * something an application should get without asking. With it off, a CAS 
logout request must not
+ * reach into the application's sessions.</p>
+ */
+@IgnoreIf({ CasTestConfig.singleSignoutEnabled })
+class CasNoSingleSignOutSpec extends AbstractCasSpec {
+
+    void 'a CAS logout request is ignored when single signout is not 
enabled'() {
+        given: 'an authenticated session established with a service ticket'
+        HttpResponse<String> loggedIn = login('/secure/users', 'user', 'user')
+
+        expect:
+        loggedIn.statusCode() == 200
+        lastServiceTicket
+
+        when: 'the same logout request that would end the session is posted'
+        postForm(backChannelClient, appBaseUrl + '/login/cas',
+                [logoutRequest: logoutRequest(lastServiceTicket)])
+
+        then: 'no single signout filter is listening, so the session survives'
+        HttpResponse<String> stillIn = followRedirects(get(appClient, 
"${appBaseUrl}/secure/users"))
+        stillIn.statusCode() == 200
+        stillIn.body().contains('Logged in with ROLE_USER')
+    }
+
+    void 'session fixation prevention is left in place when single signout is 
not enabled'() {
+        expect: 'the core plugin default stands, so logging in still works 
normally'
+        login('/secure/users', 'user', 'user').body().contains('Logged in with 
ROLE_USER')
+    }
+}
diff --git 
a/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/specs/CasProxyTicketSpec.groovy
 
b/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/specs/CasProxyTicketSpec.groovy
new file mode 100644
index 0000000000..f5f906eeb3
--- /dev/null
+++ 
b/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/specs/CasProxyTicketSpec.groovy
@@ -0,0 +1,64 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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
+ *
+ *    https://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 specs
+
+import spock.lang.IgnoreIf
+import spring.security.cas.test.CasTestConfig
+
+import java.net.http.HttpResponse
+
+/**
+ * Covers the proxy-ticket path: with {@code proxyCallbackUrl} and {@code 
proxyReceptorUrl} both
+ * configured, CAS calls back into the application while the service ticket is 
being validated, and
+ * the proxy-granting ticket it delivers is stored and retrievable.
+ */
+@IgnoreIf({ !CasTestConfig.proxyEnabled })
+class CasProxyTicketSpec extends AbstractCasSpec {
+
+    void 'a proxy ticket can be obtained for the logged-in user'() {
+        given:
+        login('/secure/users', 'user', 'user')
+
+        when:
+        HttpResponse<String> response = followRedirects(get(appClient, 
"${appBaseUrl}/secure/proxyStatus"))
+
+        then:
+        response.statusCode() == 200
+        response.body().startsWith('PROXY_TICKET:PT-')
+    }
+
+    void 'the receptor path is consumed by the CAS filter rather than the 
application'() {
+        when: 'the receptor is requested without the parameters CAS would send'
+        HttpResponse<String> response = get(appClient, appBaseUrl + 
CasTestConfig.PROXY_RECEPTOR_URL)
+
+        then: 'the CAS filter handles it instead of letting it fall through to 
a 404'
+        response.statusCode() == 200
+        !response.body()
+    }
+
+    void 'a normal CAS login still works with proxy settings configured'() {
+        when:
+        HttpResponse<String> response = login('/secure/users', 'user', 'user')
+
+        then:
+        response.statusCode() == 200
+        response.body().contains('Logged in with ROLE_USER')
+    }
+}
diff --git 
a/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/specs/CasSingleSignOutSpec.groovy
 
b/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/specs/CasSingleSignOutSpec.groovy
new file mode 100644
index 0000000000..97be094222
--- /dev/null
+++ 
b/grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/specs/CasSingleSignOutSpec.groovy
@@ -0,0 +1,75 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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
+ *
+ *    https://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 specs
+
+import spock.lang.IgnoreIf
+import spring.security.cas.test.CasTestConfig
+
+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.
+ *
+ * <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
+ * invalidating it - and posting the same message CAS would send exercises 
exactly that, without
+ * depending on how the CAS server is configured to emit it.</p>
+ */
+@IgnoreIf({ !CasTestConfig.singleSignoutEnabled })
+class CasSingleSignOutSpec extends AbstractCasSpec {
+
+    void 'a CAS logout request invalidates the session that the service ticket 
authenticated'() {
+        given: 'an authenticated session established with a service ticket'
+        HttpResponse<String> loggedIn = login('/secure/users', 'user', 'user')
+
+        expect:
+        loggedIn.statusCode() == 200
+        loggedIn.body().contains('Logged in with ROLE_USER')
+        lastServiceTicket
+
+        when: 'CAS posts a back-channel logout request naming that ticket'
+        HttpResponse<String> logoutResponse = postForm(backChannelClient,
+                appBaseUrl + '/login/cas', [logoutRequest: 
logoutRequest(lastServiceTicket)])
+
+        then: 'the single sign-out filter consumes it'
+        logoutResponse.statusCode() == 200
+
+        and: 'the session no longer authenticates, so the next request goes 
back to CAS'
+        HttpResponse<String> afterLogout = get(appClient, 
"${appBaseUrl}/secure/users")
+        afterLogout.statusCode() == 302
+        location(afterLogout).startsWith("${casBaseUrl}/login")
+    }
+
+    void 'a logout request for an unrelated ticket leaves the session alone'() 
{
+        given:
+        login('/secure/users', 'user', 'user')
+
+        when:
+        postForm(backChannelClient, appBaseUrl + '/login/cas',
+                [logoutRequest: logoutRequest('ST-does-not-exist')])
+
+        then: 'the established session is untouched'
+        HttpResponse<String> stillIn = followRedirects(get(appClient, 
"${appBaseUrl}/secure/users"))
+        stillIn.statusCode() == 200
+        stillIn.body().contains('Logged in with ROLE_USER')
+    }
+}
diff --git 
a/grails-test-examples/spring-security/cas/test1/src/main/groovy/spring/security/cas/test/CasContainerHolder.groovy
 
b/grails-test-examples/spring-security/cas/test1/src/main/groovy/spring/security/cas/test/CasContainerHolder.groovy
new file mode 100644
index 0000000000..209f122c0c
--- /dev/null
+++ 
b/grails-test-examples/spring-security/cas/test1/src/main/groovy/spring/security/cas/test/CasContainerHolder.groovy
@@ -0,0 +1,111 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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
+ *
+ *    https://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 spring.security.cas.test
+
+import groovy.transform.CompileStatic
+import groovy.util.logging.Slf4j
+import org.testcontainers.containers.GenericContainer
+import org.testcontainers.containers.output.Slf4jLogConsumer
+import org.testcontainers.containers.wait.strategy.Wait
+import org.testcontainers.utility.DockerImageName
+import org.testcontainers.utility.MountableFile
+
+import java.time.Duration
+
+/**
+ * Starts a single Apereo CAS server in a container and shares it with every 
caller in the JVM.
+ *
+ * <p>The stock {@code apereo/cas} image only authorises {@code https} 
services, so a service
+ * definition permitting {@code http} is copied in and the JSON service 
registry is initialised from
+ * it. That definition also carries the proxy policy, which is what allows CAS 
to issue
+ * proxy-granting tickets.</p>
+ *
+ * <p>Do not add {@code 
attributeReleasePolicy.authorizedToReleaseProxyGrantingTicket} to that
+ * definition. Despite the name it is a different feature - releasing the 
ticket id as an encrypted
+ * attribute - it needs a service public key, and without one CAS drops the
+ * {@code <cas:proxyGrantingTicket>} element from the validation response 
entirely, which silently
+ * breaks proxy authentication.</p>
+ */
+@Slf4j
+@CompileStatic
+class CasContainerHolder {
+
+    static final String DEFAULT_CAS_VERSION = '7.3.6'
+    static final String CONTEXT_PATH = '/cas'
+
+    private static final int CAS_PORT = 8080
+    private static final String SERVICE_DEFINITION = 'grailsTest-10000001.json'
+    private static final String SERVICE_REGISTRY_DIR = '/etc/cas/services'
+
+    private static GenericContainer container
+
+    static synchronized GenericContainer getContainer() {
+        if (container?.running) {
+            return container
+        }
+        GenericContainer started = createContainer()
+        started.start()
+        started.followOutput(new Slf4jLogConsumer(log))
+        container = started
+        started
+    }
+
+    /**
+     * The CAS base URL as seen from this JVM. Used for ticket validation and 
proxy retrieval, and
+     * for the login redirect the test client follows.
+     */
+    static String getServerUrlPrefix() {
+        GenericContainer running = getContainer()
+        
"http://${running.host}:${running.getMappedPort(CAS_PORT)}${CONTEXT_PATH}"
+    }
+
+    private static GenericContainer createContainer() {
+        String version = System.getProperty('casContainerVersion') ?: 
DEFAULT_CAS_VERSION
+        GenericContainer cas = new 
GenericContainer(DockerImageName.parse("apereo/cas:${version}"))
+        cas.withExposedPorts(CAS_PORT)
+        cas.withEnv(environment())
+        cas.withCopyFileToContainer(
+                
MountableFile.forClasspathResource("cas/services/${SERVICE_DEFINITION}"),
+                "${SERVICE_REGISTRY_DIR}/${SERVICE_DEFINITION}")
+        // CAS has to reach back into the host for single logout and the proxy 
callback. 'host-gateway'
+        // is resolved by the container runtime, so no port needs to be 
registered up front - the app
+        // port is not known until the embedded server has bound.
+        cas.withExtraHost(CasTestConfig.CONTAINER_VISIBLE_HOST, 'host-gateway')
+        
cas.waitingFor(Wait.forHttp("${CONTEXT_PATH}/login").forPort(CAS_PORT).forStatusCode(200))
+        cas.withStartupTimeout(Duration.ofMinutes(5))
+        cas
+    }
+
+    private static Map<String, String> environment() {
+        [
+                SERVER_SSL_ENABLED                    : 'false',
+                SERVER_PORT                           : CAS_PORT.toString(),
+                CAS_AUTHN_ACCEPT_ENABLED              : 'true',
+                // Must match the users created in BootStrap, since the CAS 
principal is looked up in GORM
+                CAS_AUTHN_ACCEPT_USERS                : 
'user::user,admin::admin',
+                // Permits the http proxy callback URL into the host
+                CAS_HTTP_CLIENT_ALLOW_LOCAL_URLS      : 'true',
+                CAS_SERVICE_REGISTRY_CORE_INIT_FROM_JSON: 'true',
+                CAS_SERVICE_REGISTRY_JSON_LOCATION    : 
"file:${SERVICE_REGISTRY_DIR}".toString(),
+                // The default of 10s is too tight for a test that boots an 
app between issue and validation
+                CAS_TICKET_ST_TIME_TO_KILL_IN_SECONDS : '60'
+        ]
+    }
+}
diff --git 
a/grails-test-examples/spring-security/cas/test1/src/main/groovy/spring/security/cas/test/CasServiceUrlConfigurer.groovy
 
b/grails-test-examples/spring-security/cas/test1/src/main/groovy/spring/security/cas/test/CasServiceUrlConfigurer.groovy
new file mode 100644
index 0000000000..7071251cf9
--- /dev/null
+++ 
b/grails-test-examples/spring-security/cas/test1/src/main/groovy/spring/security/cas/test/CasServiceUrlConfigurer.groovy
@@ -0,0 +1,57 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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
+ *
+ *    https://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 spring.security.cas.test
+
+import groovy.transform.CompileStatic
+import groovy.util.logging.Slf4j
+import org.apereo.cas.client.validation.Cas20ServiceTicketValidator
+import org.springframework.boot.web.server.context.WebServerInitializedEvent
+import org.springframework.context.ApplicationContext
+import org.springframework.context.ApplicationListener
+import org.springframework.security.cas.ServiceProperties
+
+/**
+ * Rewrites the two CAS URLs that depend on the port the embedded server binds.
+ *
+ * <p>Integration tests run on a random port, so the service URL and the proxy 
callback URL cannot
+ * be known when the CAS plugin defines its beans. Both are read per request 
rather than cached at
+ * startup, so setting them once the server is up - but before it serves 
anything - is enough.</p>
+ */
+@Slf4j
+@CompileStatic
+class CasServiceUrlConfigurer implements 
ApplicationListener<WebServerInitializedEvent> {
+
+    @Override
+    void onApplicationEvent(WebServerInitializedEvent event) {
+        int port = event.webServer.port
+        ApplicationContext context = event.applicationContext
+
+        ServiceProperties serviceProperties = 
context.getBean('casServiceProperties', ServiceProperties)
+        serviceProperties.service = CasTestConfig.serviceUrl(port)
+        log.info('CAS service URL set to {}', serviceProperties.service)
+
+        if (CasTestConfig.proxyEnabled) {
+            Cas20ServiceTicketValidator ticketValidator =
+                    context.getBean('casTicketValidator', 
Cas20ServiceTicketValidator)
+            ticketValidator.proxyCallbackUrl = 
CasTestConfig.proxyCallbackUrl(port)
+            log.info('CAS proxy callback URL set to {}', 
CasTestConfig.proxyCallbackUrl(port))
+        }
+    }
+}
diff --git 
a/grails-test-examples/spring-security/cas/test1/src/main/groovy/spring/security/cas/test/CasTestConfig.groovy
 
b/grails-test-examples/spring-security/cas/test1/src/main/groovy/spring/security/cas/test/CasTestConfig.groovy
new file mode 100644
index 0000000000..02b4b1e62d
--- /dev/null
+++ 
b/grails-test-examples/spring-security/cas/test1/src/main/groovy/spring/security/cas/test/CasTestConfig.groovy
@@ -0,0 +1,72 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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
+ *
+ *    https://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 spring.security.cas.test
+
+import groovy.transform.CompileStatic
+
+/**
+ * Shared naming for the CAS test setup.
+ *
+ * <p>The app is reachable under two different host names depending on who is 
doing the reaching:
+ * the test client and the app itself use {@code localhost}, while the CAS 
server runs in a
+ * container and must call back into the host via {@code 
host.testcontainers.internal}. Anything
+ * CAS is told to call (the service URL it redirects to and later posts 
single-logout requests to,
+ * and the proxy callback URL) therefore uses the container-visible host 
name.</p>
+ */
+@CompileStatic
+class CasTestConfig {
+
+    /** Host name a container uses to reach the Docker host. */
+    static final String CONTAINER_VISIBLE_HOST = 'host.testcontainers.internal'
+
+    /** Matches the CAS plugin default {@code filterProcessesUrl}. */
+    static final String CAS_FILTER_PROCESSES_URL = '/login/cas'
+
+    static final String PROXY_RECEPTOR_URL = '/secure/receptor'
+
+    /** Runs the app with {@code proxyCallbackUrl} / {@code proxyReceptorUrl} 
configured. */
+    static final String PROXY_TEST_CONFIG = 'casProxy'
+
+    /** Runs the app with both proxy settings left unset. */
+    static final String DEFAULT_TEST_CONFIG = 'cas'
+
+    /** Runs the app without opting in to single signout, to assert the 
shipped default. */
+    static final String NO_SINGLE_SIGNOUT_TEST_CONFIG = 'casNoSingleSignout'
+
+    static String getTestConfig() {
+        System.getProperty('TESTCONFIG') ?: DEFAULT_TEST_CONFIG
+    }
+
+    static boolean isProxyEnabled() {
+        testConfig == PROXY_TEST_CONFIG
+    }
+
+    static boolean isSingleSignoutEnabled() {
+        testConfig != NO_SINGLE_SIGNOUT_TEST_CONFIG
+    }
+
+    static String serviceUrl(int port) {
+        "http://${CONTAINER_VISIBLE_HOST}:${port}${CAS_FILTER_PROCESSES_URL}";
+    }
+
+    static String proxyCallbackUrl(int port) {
+        "http://${CONTAINER_VISIBLE_HOST}:${port}${PROXY_RECEPTOR_URL}";
+    }
+}
diff --git 
a/grails-test-examples/spring-security/cas/test1/src/main/groovy/spring/security/cas/test/CasTestEnvironmentPostProcessor.groovy
 
b/grails-test-examples/spring-security/cas/test1/src/main/groovy/spring/security/cas/test/CasTestEnvironmentPostProcessor.groovy
new file mode 100644
index 0000000000..b2bbc6b9ba
--- /dev/null
+++ 
b/grails-test-examples/spring-security/cas/test1/src/main/groovy/spring/security/cas/test/CasTestEnvironmentPostProcessor.groovy
@@ -0,0 +1,62 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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
+ *
+ *    https://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 spring.security.cas.test
+
+import groovy.transform.CompileStatic
+import org.springframework.boot.EnvironmentPostProcessor
+import org.springframework.boot.SpringApplication
+import org.springframework.core.env.ConfigurableEnvironment
+import org.springframework.core.env.MapPropertySource
+import org.springframework.core.env.MutablePropertySources
+
+/**
+ * Points the CAS plugin at the containerised CAS server before the 
application context is built.
+ *
+ * <p>The plugin reads {@code cas.serverUrlPrefix} while it is defining beans, 
so the container has
+ * to be running by then. The two URLs CAS calls back on depend on the port 
the embedded server ends
+ * up binding, which is not known this early; they are given a placeholder 
here and corrected by
+ * {@link CasServiceUrlConfigurer} once the server has started.</p>
+ */
+@CompileStatic
+class CasTestEnvironmentPostProcessor implements EnvironmentPostProcessor {
+
+    static final String PROPERTY_SOURCE_NAME = 'casTestContainer'
+
+    private static final String PREFIX = 'grails.plugin.springsecurity.cas.'
+    private static final int PLACEHOLDER_PORT = 0
+
+    @Override
+    void postProcessEnvironment(ConfigurableEnvironment environment, 
SpringApplication application) {
+        MutablePropertySources propertySources = environment.propertySources
+        if (propertySources.contains(PROPERTY_SOURCE_NAME)) {
+            return
+        }
+
+        Map<String, Object> properties = [:]
+        properties.put("${PREFIX}serverUrlPrefix".toString(), 
CasContainerHolder.serverUrlPrefix)
+        properties.put("${PREFIX}serviceUrl".toString(), 
CasTestConfig.serviceUrl(PLACEHOLDER_PORT))
+        if (CasTestConfig.proxyEnabled) {
+            properties.put("${PREFIX}proxyReceptorUrl".toString(), 
CasTestConfig.PROXY_RECEPTOR_URL)
+            properties.put("${PREFIX}proxyCallbackUrl".toString(), 
CasTestConfig.proxyCallbackUrl(PLACEHOLDER_PORT))
+        }
+
+        propertySources.addFirst(new MapPropertySource(PROPERTY_SOURCE_NAME, 
properties))
+    }
+}
diff --git 
a/grails-test-examples/spring-security/cas/test1/src/main/resources/META-INF/spring.factories
 
b/grails-test-examples/spring-security/cas/test1/src/main/resources/META-INF/spring.factories
new file mode 100644
index 0000000000..5c1628ce06
--- /dev/null
+++ 
b/grails-test-examples/spring-security/cas/test1/src/main/resources/META-INF/spring.factories
@@ -0,0 +1,19 @@
+#
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you 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
+#
+#    https://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.
+#
+org.springframework.boot.EnvironmentPostProcessor=spring.security.cas.test.CasTestEnvironmentPostProcessor
diff --git 
a/grails-test-examples/spring-security/cas/test1/src/main/resources/cas/services/grailsTest-10000001.json
 
b/grails-test-examples/spring-security/cas/test1/src/main/resources/cas/services/grailsTest-10000001.json
new file mode 100644
index 0000000000..e004e8e3a1
--- /dev/null
+++ 
b/grails-test-examples/spring-security/cas/test1/src/main/resources/cas/services/grailsTest-10000001.json
@@ -0,0 +1,12 @@
+{
+  "@class": "org.apereo.cas.services.CasRegisteredService",
+  "serviceId": "^https?://.*",
+  "name": "grailsTest",
+  "id": 10000001,
+  "evaluationOrder": 1,
+  "description": "Permits the http test application and allows it to obtain 
proxy-granting tickets.",
+  "proxyPolicy": {
+    "@class": 
"org.apereo.cas.services.RegexMatchingRegisteredServiceProxyPolicy",
+    "pattern": "^https?://.*"
+  }
+}

Reply via email to