Implementing Secure RSocket services with Spring Boot

Mario Gray

This guide will discuss RSocket service security with Spring Boot, by way of Spring Security. We will surface RSocket routes that enforce specific security measures and describe what this means internally. This guide will inform you of the additional configuration options provided when configuring for Spring Security on a Spring Boot 2.7.x/RSocket application.

It is assumed the developer knows Kotlin, uses Spring Boot, and has an understanding of the Reactive Streams on the JVM. If you’re new to Spring Security for Reactive streams, then this guide should help shed light on the subject. Of course, the best place to understand are the reference docs, so please read them!

Authorization vs Authentication

Authentication is the process which lets our apps identify a user. Authentication systems are methods which describe a secure process of identification. For example you’re familiar with multi factor authentication which uses username and password followed by a side-channel delivered code (via text message, Email, etc..). You might also have used SSO (Single Sign-n) which aggregates multiple backends that coordinate a single authentication session for the user. At the end of the day, they still rely on some form of user input - maybe a face, a fingerprint, or plain old username and password.

This example will focus on username and password authentication to illustrate the mechanism underneath which are similar regardless of authentication method.

Authorization (access control) is the process which lets your application determine how access is granted to users. This begins to sound straight forward, but can be surfaced in our application in a number of ways. OAuth is popular since it allows an application share user-rights with an unrelated system. On top of OAuth is usually Role Based Access Control - in which a user may have granted privileges given by role ’names’ - e.g. ‘WRITE’, ‘READ’ for a given resource. Additionally, RBAC relies on the application to make these decisions as you will see later in this guide.

The Application

The Example app is an RSocket service we will use to test authentication and authorization.

The service interface is as follows:

interface TreeService {
    fun shakeForLeaf(): Mono<String>

    companion object {
        val LEAF_COLORS = listOf("Green", "Yellow", "Orange", "Brown", "Red")
    }
}

Above, we have a function that returns a Mono<String> of colors.

We can then write the backing implementation:

class TreeServiceImpl : TreeService {
    override fun shakeForLeaf(): Mono<String> = Mono.just(LEAF_COLORS.get(Random.nextInt(LEAF_COLORS.size)))
}

Subclass the service interface to create the RSocket controller using @MessageMapping:

interface TreeControllerMapping : TreeService {
    @MessageMapping("shake")
    override fun shakeForLeaf(): Mono<String>

    @MessageMapping("status")
    fun status(@AuthenticationPrincipal user: Mono<UserDetails>): Mono<String> =
            user.hasElement().map (Boolean::toString)    
}

The open ‘status’ route contains an argument decorated with @AuthenticationPrincipal to inject - if available - the current logged in user.

Next, subclass our service once more and apply Spring Security annotations. Use @PreAuthorize, which is the preferred way for securing reactive streams through annotation.

interface TreeServiceSecurity : TreeService {
    @PreAuthorize("hasRole('SHAKE')")
    override fun shakeForLeaf(): Mono<String>
}

Finally, we can put the whole thing together and expose it as an RSocket server with help from Spring Boot!

Configuring the App

The production application will turn on various subsystems for Spring Security to work with Reactive streams, and RSocket services. We also expose the service using @Controller by composing security, message-mapping routes, and backing service implementation. The listing below shows this configuration:

@EnableReactiveMethodSecurity  // 1
@EnableRSocketSecurity // 2
@SpringBootApplication
class App {

 // ...

    @Controller
    class ServerTreeController : TreeControllerMapping,
            TreeServiceSecurity, TreeService by TreeServiceImpl()  // 3

// ...

    companion object {
        @JvmStatic
        fun main(args: Array<String>) {
            runApplication<App>(*args)
        }
    }
}

Here are the steps in enabling Reactive method, and RSocket security:

  1. Enable the RSocketSecurity bean by decorating a configuration class with @EnableRSocketSecurity. What this does is as stated in documentation - it allows configuring RSocket based security.
  2. Enable security-specific annotations on Reactive Streams (return types of Publisher) with the @EnableReactiveMethodSecurity annotation to the main configuration class.
  3. The RSocket messaging controller is fully configured here.

Review of User Management

Spring Security provides concrete User objects that implement the UserDetails interface. The User.UserBuilder object provides a fluent builder for describing instances of User.

We will also need to reactively read/write users to some kind of storage. This activity is exposed for Reactive services through ReactiveUserDetailsService. The easiest way to use this is by creating an instance of the in-memory MapReactiveUserDetailService.

To review, we can completely populate a ReactiveUserDetailService in our production app:

class App {
    // ...
    @Bean
    open fun userDetailService(): ReactiveUserDetailsService =
            MapReactiveUserDetailsService(
                    User.builder()
                            .username("plumber")
                            .password("{noop}nopassword")   // 1
                            .roles("SHAKE")
                            .build(),
                    User.builder()
                            .username("gardner")
                            .password("{noop}superuser")
                            .roles("RAKE", "LOGIN")
                            .build()
            )
}

The above code sample reads well, but there is some nuance with the password specification:

  1. The builder supports the algorithm hint using curly braces. Here we specify noop (plaintext) password encoding. In the background, Spring Security uses an DelegatingPasswordEncoder to determine the proper encoder to use such as pbkdf2, scrypt, sha256, etc…

WARNING: Please do not use plaintext {noop} in production! A good write-up describing the evolution of password storage and hints for what you can do to prevent tampering can be found in the Spring Security docs

About User Resolution

Spring Security uses a ReactiveSecurityContextHolder to manage a SecurityContext in Reactor’s Context. Spring Security (or the developer) can then use a AuthenticationPrincipalArgumentResolver by way of an RSocketMessageHandler to access this context, and resolve the logged in user at method level.

There is a nice to know informal example that describes how one would resolve a custom User object with the AuthenticationPrincipalArgumentResolver docs.

Review of RSocket Server Security

By using @EnableRSocketSecurity, we gain RSocket security through Payload Interceptors. Interceptors themselves are cross-cutting, and Spring Security uses them to work on processing at various parts of an interaction such as:

  • Transport level
  • At the level of accepting new connections
  • Performing requests
  • Responding to requests

Since a payload can have many metadata formats to confer credential exchange, Spring’s RSocketSecurity bean provides a fluent builder for configuring Simple, Basic, JWT, and custom authentication methods, in addition to RBAC authorization.

The RSocketSecurity provided builder will describe a set of AuthenticationPayloadInterceptors that converts payload metadata into an Authentication instances inside the SecurityContext.

To further our understanding of the configuration, lets examine the SecuritySocketAcceptorInterceptorConfiguration class, which sets up the default security configuration for RSocket.

This class, imported by @EnableRSocketSecurty, will configure a PayloadSocketAcceptorInterceptor for Simple and basic authentications. The PayloadExchangeMatchers describe which exchanges require authentication:

package org.springframework.security.config.annotation.rsocket;

class SecuritySocketAcceptorInterceptorConfiguration {
    //...
	private PayloadSocketAcceptorInterceptor defaultInterceptor(ObjectProvider<RSocketSecurity> rsocketSecurity) {
		rsocket.basicAuthentication(Customizer.withDefaults())  // 1
			.simpleAuthentication(Customizer.withDefaults())    // 2
			.authorizePayload((authz) -> authz                  // 3
				.setup().authenticated()                        // 4
				.anyRequest().authenticated()                   // 5
				.matcher((e) -> MatchResult.match()).permitAll() // 6
			);
    //...
}

The authorizePayload method decides how we can apply authorization at connection setup and request exchanges. The configuration above include:

  1. Basic credential passing for backwards compatibility; this is deprecated in favor of #2
  2. Simple credential passing is supported by default; this is the winning spec and supersedes Basic.
  3. Access control rules that specifies which exchanges must be authenticated before being granted access to the server.
  4. Builds a PayloadExchangeMatcher to ensures that SETUP exchanges require authentication metadata.
  5. Builds another PayloadExchangeMatcher for request exchanges requiring authentication.
  6. This custom matcher matches everything, and permits access.

Request vs Setup: The PayloadExchangeType defines any request exchange as one of the following internal RSocket Protocol units of operation; FIRE_AND_FORGET, REQUEST_RESPONSE, REQUEST_STREAM, REQUEST_CHANNEL and METADATA_PUSH. SETUP and PAYLOAD (payload frames can have metadata) units are considered SETUP exchanges.

Review of Reactive Method Security

With the usage of @EnableReactiveMethodSecurity in our main class, we gained the ability to annotate reactive streams with rules for authorization. This happens mainly in the ReactiveAuthorizationManager instances for specific use cases. Out of the box, we get the support for a variety of expressions with @PreAuthorize to introspect the authenticated user for necessary privileges. There are a variety of built-in expressions that we can use.

Here are built-in expressions supported as defined in SecurityExpressionOperations and described in the Docs:

ExpressionDescription
hasRole(role: String)Returns true if the current principal has the specified role.
For example, hasRole(‘admin’) By default if the supplied role does not start with ‘ROLE_’ it will be added. This can be customized by modifying the defaultRolePrefix on DefaultWebSecurityExpressionHandler.
hasAnyRole(vararg roles: String)Returns true if the current principal has any of the supplied roles (given as a comma-separated list of strings)
hasAuthority(authority: String)Returns true if the current principal has the specified authority. For example, hasAuthority('read')
hasAnyAuthority(vararg authorities: String)Returns true if the current principal has any of the supplied authorities (given as a comma-separated list of strings) For example, hasAnyAuthority('read', 'write')
principalAllows direct access to the principal object representing the current user
authenticationAllows direct access to the current Authentication object obtained from the SecurityContext
permitAllAlways evaluates to true
denyAllAlways evaluates to false
isAnonymous()Returns true if the current principal is an anonymous user
isRememberMe()Returns true if the current principal is a remember-me user
isAuthenticated()Returns true if the user is not anonymous
isFullyAuthenticated()Returns true if the user is not an anonymous or a remember-me user
hasPermission(target: Any, permission: Any)Returns true if the user has access to the provided target for the given permission. For example, hasPermission(domainObject, 'read')
hasPermission(targetId: Any, targetType: String, permission: Any)Returns true if the user has access to the provided target for the given permission. For example, hasPermission(1, 'com.example.domain.Message', 'read')

To gain fundamental understanding of the underpinnings of Authorization, I encourage you to read the Spring Docs. This documentation is robust and does well in describing exactly how Authorization operates under the hood - especially for situations where you have legacy framework code and want to customize.

CURRENTLY: For custom expressions, Spring Security supports return values of boolean and cannot be wrapped in deferred values such as a reactive Publisher. As such, the expressions must not block.

Security at the Client

Spring RSocket creates a RSocketRequesterBuilder bean at startup. This bean provides a builder for creating new RSocketRequesters. An RSocketRequester provides a single connection interface to RSocket operations usually across a network.

RSocket Security can be applied at the setup and or request levels. We will discuss both methods next.

Authentication Styles on the Client

We can secure the entire RSocket connection by sending metadata in the SETUP frame. The RSocketRequester.Builder builder lets us specify setupMetadata that contains authentication metadata.

Our custom RequestFactory class makes it so we don’t repeat the connection builder every time a requester is needed. We either need an authenticated connection or a non-authenticated connection. We will create the authenticating Requester below:

open class RequesterFactory(private val port: String) {
        companion object {
        val SIMPLE_AUTH = MimeTypeUtils.parseMimeType(WellKnownMimeType.MESSAGE_RSOCKET_AUTHENTICATION.string) // 1
    }
    open fun authenticatedRequester(username: String, password: String): RSocketRequester =
            RSocketRequester
                    .builder()
                    .rsocketStrategies { strategiesBuilder ->
                        strategiesBuilder.encoder(SimpleAuthenticationEncoder())
                    } // 2
                    .setupMetadata(UsernamePasswordMetadata(username, password), SIMPLE_AUTH) //3
                    .connectTcp("localhost", port.toInt())
                    .block()!!
 //..
}    

The lines of code we want to inspect here relate to the specifics for setup frame authentication metadata:

  1. Requester needs to know how to encode our Simple authentication metadata.
  2. Which needs to be registered as an encoder in Spring’s RSocketStrategies.
  3. Then use setupMetadata to encode credentials going into the setup frame.

Next, we need a non-authenticated requester:

    open fun requester(): RSocketRequester =
            RSocketRequester
                    .builder()
                    .rsocketStrategies { strategiesBuilder ->
                        strategiesBuilder.encoder(SimpleAuthenticationEncoder())
                    }
                    .connectTcp("localhost", port.toInt())
                    .block()!!

We need to keep the strategy encoder for Simple authentication so that we can still send authenticated requests at request time. Other than that, nothing else is different.

Next, we can create some tests to demonstrate connectivity and test whether our configuration is valid.

Testing the Client and Server

Lets produce some integration tests. We want to standup the RSocketServer on it’s network port, then send real authenticated frames over the wire. We will also know whether authenticated connections are acting secure by ensuring proper rejection of an unauthenticated setup frame.

@SpringBootTest         // 1
class RequesterFactoryTests {
    @Test
    fun `no setup authentication is REJECTEDSETUP`(@Autowired requesterFactory: RequesterFactory) {
        val requester = requesterFactory.requester()    // 2

        val request = requester
                .route("status")
                .retrieveMono<String>() // 3

        StepVerifier
                .create(request)
                .verifyError(RejectedSetupException::class.java)  //4
    }

Lets inspect what this test means:

  1. Using @SpringBootTest ensures we get full autowiring of our production code to configure the RSocket server.
  2. Create a requester that omits authentication metadata in the setup frame.
  3. The test site is simple and merely sends a request to the status route that returns whether we are authenticated or not.
  4. The ‘status’ route is not locked down, but our server configuration states that connection setup must be authenticated. Thus, we will expect a RejectedSetupExeption error upon request.

Next, we will test when we send authenticated requests without sending the authentication setup frame:

    @Test
    fun `sends credential metadata in request is REJECTEDSETUP`(@Autowired requesterFactory: RequesterFactory) {
        val requester = requesterFactory.requester()

        val request = requester
                .route("status")
                .metadata(UsernamePasswordMetadata("shaker", "nopassword"), RequesterFactory.SIMPLE_AUTH) // 1
                .retrieveMono<String>()

        StepVerifier
                .create(request)
                .verifyError(RejectedSetupException::class.java)    // 2
    }

This test case is very similar to the previous one except:

  1. We only authenticate the request alone with Simple authentication.
  2. This still wont work, and will result with RejectedSetupException since our server expects authentication in the setup frame.

Authorization Tests

Next, we will perform proper setup, and test for route authorization. Recall earlier we have a TreeServiceSecurity class that adds @PreAuthorize to our service methods. Lets test this with a User of insufficient privilege:

    @Test
    fun `underprivileged shake request is APPLICATIONERROR Denied`(@Autowired requesterFactory: RequesterFactory) {
        val request = requesterFactory.requester("raker", "nopassword")
                .route("shake")
                .retrieveMono<String>()

        StepVerifier
                .create(request)
                .verifyError(ApplicationErrorException::class.java)
    }

This test will:

  1. Create the authenticated requester. Remember; this sends authentication in the setup frame. Thus, requests will be allowed.
  2. Send a request to the ‘shake’ route. This service method is @PreAuthorized protected for users having ‘SHAKE’ role.
  3. Since we don’t have this kind of permission for the ‘raker’ user, we will get ApplicationErrorException with the message ‘Denied’.

NOTE: To ensure safer communication while using Simple authentication, you might apply TLS security across the transport. This way, no one can snoop the network for unencrypted authentication payloads.

Method Security Tests

We can get better test unit isolation by removing or ignoring the RSocketServer, and issuing requests directly to the service instance. This can be done using a compliment of method testing supports provided out of the box by Spring Security.

For example, we want to test that authorization on the shakeForLeaves() service method which requires Users with the ‘shake’ role. We can actually mock a user in tests by decorating our test method using the @WithMockUser annotation:

@SpringBootTest
class MethodSecurityTests {
    @Test
    @WithMockUser("testuser", roles = ["SHAKE"]) //1
    fun `should return success calling shake with given mockUser`(@Autowired svc: TreeService) {
        StepVerifier
                .create(svc.shakeForLeaf())
                .assertNext {
                    Assertions
                            .assertThat(it)
                            .isNotNull
                            .containsAnyOf(*TreeService.LEAF_COLORS.toTypedArray())
                }
    }

We can also test with users populated from our own ReactiveUserDetailsService with help from the @WithUserDetails annotation as follows:

    @Test
    @WithUserDetails("shaker")
    fun `should return success calling shake with given withUserDetails`(@Autowired svc: TreeService) {
            StepVerifier
                .create(svc.shakeForLeaf())
                .assertNext {
                    Assertions
                            .assertThat(it)
                            .isNotNull
                            .containsAnyOf(*TreeService.LEAF_COLORS.toTypedArray())
                }
                .verifyComplete()
    }

There is more to testing secure methods in reactive environments. To learn more about Spring Security test support, check out the docs which give detailed explanation and examples for the above mentioned supports and more!

Closing and Next Steps

This guide introduced you to Spring Boot and Spring Security with RSocket. One key take-away, that Spring Security configuration can allow Simple or other authentication schemes such as JWT and Kerberos. Understanding how permissions work out of the box in Spring Security, and applying authorization to Reactive Methods helps when custom logic is needed. Furthermore, it is safer to use some form of transport security when implementing simple RSocket security because authentication frames are transmitted plaintext. We will discuss securing RSocket connections with TLS in another guide.

Then next step on this topic will take advantage of Spring Security’s JWT interface. For in-depth implementation details on that topic now, please see the Spring Security Samples project on Github.

Informational and Learning Material

Ben Wilcock’s Getting Started to RSocket Security

Going coroutine with Reactive Spring Boot

Spring Security Reference

Spring Security Testing

Spring Shell Reference

Building WebApps with Spring-Boot and Kotlin

JWT RFC 7519

XACML for when RBAC is not enough