Skip to content

Adapt plain values in reactive method security#19435

Open
kamilkrzywanski wants to merge 1 commit into
spring-projects:mainfrom
kamilkrzywanski:gh-19400
Open

Adapt plain values in reactive method security#19435
kamilkrzywanski wants to merge 1 commit into
spring-projects:mainfrom
kamilkrzywanski:gh-19400

Conversation

@kamilkrzywanski

Copy link
Copy Markdown

Closes

Closes gh-19400

Context

When a Kotlin suspend method is annotated with both @PreAuthorize and
@Cacheable under reactive method security, the first request succeeds
(cache miss) but a subsequent request fails with:

java.lang.ClassCastException: class DemoResponse cannot be cast to class reactor.core.publisher.Mono
    at AuthorizationManagerBeforeReactiveMethodInterceptor.lambda$invoke$5(...)

Root cause

  1. Spring AOP invokes Kotlin suspending functions as Mono.
  2. @PreAuthorize (order ~200) is the outer advisor; @Cacheable
    (LOWEST_PRECEDENCE) is the inner advisor.
  3. On a cache hit, Spring Framework's CacheAspectSupport returns the
    plain cached value for suspending functions (return type is Object,
    so no ReactiveAdapter is applied in findInCaches).
  4. Reactive method security assumed mi.proceed() always returned a
    Mono/Flux, so Mono.defer(() -> proceed(mi)) checkcasts the plain
    value and throws ClassCastException.

Solution

Normalize the result of proceed() in reactive method security interceptors:

  • If the result is already a Mono/Flux/Publisher, use it as-is
  • Otherwise wrap plain values with Mono.justOrEmpty(...) /
    Flux.just(...)

This matches how Spring AOP already handles mixed return shapes at the
proxy boundary (awaitSingleOrNull).

Changes

  • ReactiveMethodInvocationUtils: add proceedAsMono / proceedAsFlux
  • AuthorizationManagerBeforeReactiveMethodInterceptor
  • AuthorizationManagerAfterReactiveMethodInterceptor
  • PrePostAdviceReactiveMethodInterceptor (deprecated path, same fix)

Tests

  • Unit tests simulating a cache hit (plain value from proceed()) for
    suspending methods on both before and after interceptors
  • Integration test combining @PreAuthorize, @Cacheable, and a Kotlin
    suspend method (cache miss + cache hit)
./gradlew :spring-security-core:test \
  --tests 'org.springframework.security.authorization.method.AuthorizationManagerBeforeReactiveMethodInterceptorTests' \
  --tests 'org.springframework.security.authorization.method.AuthorizationManagerAfterReactiveMethodInterceptorTests' \
  :spring-security-config:test \
  --tests 'org.springframework.security.config.annotation.method.configuration.KotlinReactiveMethodSecurityCacheableTests'

Notes

Spring Framework's CacheAspectSupport still does not treat suspending
functions as reactive on the cache-hit path. This change hardens Spring
Security against that (and any other advisor that returns a plain value
for a suspending method). A complementary Framework fix may still be
worthwhile for consistency.

Kotlin suspend methods with @Cacheable can return a plain
cached value on a cache hit. Reactive method security
assumed proceed() always returned a Mono or Flux, which
caused a ClassCastException.

Normalize proceed() results so plain values are adapted
to Mono or Flux.

Closes spring-projectsgh-19400

Signed-off-by: Kamil Krzywannski <kamilkrzywanski01@gmail.com>
@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged label Jul 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status: waiting-for-triage An issue we've not yet triaged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ClassCastException with @PreAuthorize and @Cacheable on Kotlin suspend method

2 participants