jdaugherty commented on code in PR #16174:
URL: https://github.com/apache/grails-core/pull/16174#discussion_r3824861585


##########
grails-doc/src/en/guide/security/securityPlugins/springSecurity/cas/usage.adoc:
##########
@@ -25,7 +25,7 @@ under the License.
 Configuring your CAS server is beyond the scope of this document. There are 
many different approaches and this will most likely be done by IT staff. It's 
assumed here that you already have a running CAS server.
 ====
 
-https://www.jasig.org/cas[CAS] is a popular single sign-on implementation. 
It's open source and has an Apache-like license, and is easy to get started 
with but is also highly configurable. In addition it has clients written in 
Java, .Net, PHP, Perl, and other languages.
+https://apereo.github.io/cas[CAS] is a popular single sign-on implementation. 
It's open source and has an Apache-like license, and is easy to get started 
with but is also highly configurable. In addition it has clients written in 
Java, .Net, PHP, Perl, and other languages.

Review Comment:
   Not this line, but this file: the `=== Single Signout` section below (line 
62) still says "Single signout is enabled by default" and only documents how to 
disable it. Since this PR flips `cas.useSingleSignout` to opt-in, that section 
needs updating to match the new default (off, enable explicitly).



##########
grails-test-examples/spring-security/cas/README.md:
##########
@@ -14,21 +14,77 @@ See the License for the specific language governing 
permissions and
 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/grails/plugin/springsecurity/cas/test/CasContainerHolder.groovy),
+and 
[CasTestEnvironmentPostProcessor](test1/src/main/groovy/grails/plugin/springsecurity/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 three configurations, selected with the 
`TESTCONFIG` system
+property. Each has to be its own run, because the configuration is applied at 
application startup.
+
+The specs only run when one of these is selected, so a build that covers every 
Spring Security
+example does not repeat a configuration that the dedicated per-configuration 
CI job already runs.
+Running `check` without `-DTESTCONFIG` therefore reports no CAS tests.
+
+| `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

Review Comment:
   Under `bootRun`, `TESTCONFIG` is unset, so `CasTestEnvironmentPostProcessor` 
never configures `proxyReceptorUrl`/`proxyCallbackUrl` and this endpoint can 
only ever return `NO_PROXY_TICKET`. A developer following this section will 
conclude the proxy support is broken. Worth either noting that limitation here 
or documenting how to start `bootRun` with the proxy configuration so the 
endpoint can actually be exercised.



##########
grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/grails/plugin/springsecurity/cas/test/CasSingleSignOutSpec.groovy:
##########
@@ -0,0 +1,74 @@
+/*
+ *  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 grails.plugin.springsecurity.cas.test
+
+import spock.lang.IgnoreIf
+
+import java.net.http.HttpResponse
+
+/**
+ * Covers single sign-out, which the plugin enables by default via {@code 
cas.useSingleSignout} by

Review Comment:
   "which the plugin enables by default" is stale — this PR makes 
`cas.useSingleSignout` opt-in (default `false`), which is exactly what 
`CasNoSingleSignOutSpec` asserts. Suggest rewording to something like "which 
the app opts into via `cas.useSingleSignout`".



##########
grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/grails/plugin/springsecurity/cas/test/CasLoginSpec.groovy:
##########
@@ -0,0 +1,92 @@
+/*
+ *  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 grails.plugin.springsecurity.cas.test
+
+
+import spock.lang.IgnoreIf
+
+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.
+ */
+@IgnoreIf({ !CasTestConfig.configured })
+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,

Review Comment:
   This re-implements the execution-token extraction inline, duplicating 
`AbstractCasSpec.extractExecution` but dropping the null guard the other paths 
get. If a future `casContainerVersion` renders the login form with different 
attribute order or quoting, `execution` is null here and `postForm` fails with 
an opaque NPE from `URLEncoder.encode(null)` inside the when-block, instead of 
the clear missing-token assertion `authenticateAtCas` produces. Reuse the 
base-class extraction and guard.



##########
grails-test-examples/spring-security/cas/test1/grails-app/controllers/grails/plugin/springsecurity/cas/test/SecureController.groovy:
##########
@@ -0,0 +1,58 @@
+/*
+ *  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 grails.plugin.springsecurity.cas.test
+
+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 {
+

Review Comment:
   The body of this file is indented with tabs; `.editorconfig` sets 
`indent_style = space` (4 spaces) for Groovy sources. Since git treats this as 
a new file (the old controller path became `resources.groovy` in rename 
detection), it should come in with the standard indentation.



##########
grails-doc/src/en/guide/upgrading/upgrading80x.adoc:
##########
@@ -2632,3 +2632,36 @@ 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

Review Comment:
   Sections 46 (Startup Banner, line 2481) and 47 (Message Bundles, line 2517) 
already exist above, so this should be `==== 48.` to keep the numbering 
sequential.



##########
grails-spring-security/cas/plugin/src/main/groovy/grails/plugin/springsecurity/cas/SpringSecurityCasGrailsPlugin.groovy:
##########
@@ -87,10 +91,29 @@ 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

Review Comment:
   With `log.warn` removed, the `@Slf4j` annotation added above is now unused. 
Two options: drop the annotation, or keep both outputs like the core plugin 
does (`SpringSecurityCoreGrailsPlugin` pairs `println` with `log.warn` for its 
warnings). The docs added in this PR say the plugin "logs a warning at 
startup", and an app that captures logs but not stdout will never see a 
`println`, so pairing them seems preferable. Also, the line just below is 
whitespace-only and should be trimmed.



##########
grails-test-examples/spring-security/cas/test1/src/integration-test/groovy/grails/plugin/springsecurity/cas/test/AbstractCasSpec.groovy:
##########
@@ -0,0 +1,178 @@
+/*
+ *  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 grails.plugin.springsecurity.cas.test
+
+import grails.testing.mixin.integration.Integration
+import spock.lang.Requires
+import spock.lang.Specification
+
+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()

Review Comment:
   Only a connect timeout is set here — `client.send()` has no request timeout 
and none of the specs use `@Timeout`, so if the CAS container accepts the TCP 
connection but never responds (mid-startup stall, OOM), `get()`/`postForm()` 
block indefinitely and the CI matrix job hangs until the runner's job-level 
kill. Consider adding `.timeout(Duration.ofSeconds(...))` to the `HttpRequest` 
builders (or a Spock `@Timeout`) so a wedged container fails within minutes 
instead.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to