To solve this challenge in Kubernetes, I had to change the WebSocket library. Simply put, the '@stomp/stompjs' library (v6.1.2) cannot upgrade from http to ws. After trying out several libraries (yes, JavaScript WebSocket libraries seem to be a dime a dozen out there!), I settled on react-stomp-hooks (v2.1.0).
For those who might be curious about the fuller solution, read on.
In React (TS), the 'react-stomp-hook' library comes with a convenient Provider component for connecting to the server. This may be added to App.tsx like so:
...
import {
StompSessionProvider,
} from "react-stomp-hooks";
import WebSocketSubscriptions from './shared/WebSocketSubscription';
...
const socketHeaders = {
login: token.userId,
passcode: <can be anything>
}
return (
<React.Fragment>
{token &&
<StompSessionProvider
url={server.name}
connectHeaders={socketHeaders}>
<WebSocketSubscriptions />
</StompSessionProvider>}
...
The url ('server.name') is from an environment variable defined as:
REACT_APP_BASE_URL_SOCKET=/ws
for Kubernetes deployment, and:
REACT_APP_BASE_URL_SOCKET=http://localhost:8000/ws
in the local development environment.
In order to work, the web socket's connection header requires to pass down a user identifier to match with Spring Security's user authentication (shown below). In my example, this is provided by the OAuth 2.0 JWT token. However, the "passcode", although a required parameter, can be anything. Note the Provider enables subscriptions to be defined in a separate component (or components) using a useSubscription hook, for example:
import {
useSubscription,
} from "react-stomp-hooks";
const WebSocketSubscriptions: React.FC = () => {
useSubscription("/app/subscribe", (message) => {
console.log(`..subscribed ${message}`);
});
...
The React-serving NGINX proxy configuration is as follows (you can see the fuller implementation in my question above):
...
location /ws {
proxy_pass http://bar-api-service.default.svc.cluster.local:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_cache_bypass $http_upgrade;
}
...
Meanwhile, the Kubernetes NGINX ingress controller need only deal with secure termination of traffic, like:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: foo-ingress
annotations:
kubernetes.io/ingress.class: nginx
nginx.ingress.kubernetes.io/rewrite-target: /
cert-manager.io/cluster-issuer: letsencrypt
spec:
tls:
- hosts:
- foo.com
secretName: tls-secret
rules:
- host: foo.com
...
The heavy-lifting now comes in the Spring Boot (v2.7.3) API where the WebSocket server resides. Addition of Spring Security is what makes the solution hairy.
At least the following dependencies are required:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-messaging</artifactId>
<version>5.6.2</version>
</dependency>
First let's look at the websocket's support files:

The AuthChannelInterceptorAdaptor class authenticates the user:
@Component
public class AuthChannelInterceptorAdaptor implements ChannelInterceptor {
private static final String USERNAME_HEADER = "login";
private static final String PASSWORD_HEADER = "passcode";
private final WebSocketAuthenticatorService webSocketAuthenticatorService;
@Autowired
public AuthChannelInterceptorAdaptor(final WebSocketAuthenticatorService webSocketAuthenticatorService) {
this.webSocketAuthenticatorService = webSocketAuthenticatorService;
}
@Override
public Message<?> preSend(final Message<?> message, final MessageChannel channel)
throws AuthenticationException {
final StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (StompCommand.CONNECT == accessor.getCommand()) {
final String username = accessor.getFirstNativeHeader(USERNAME_HEADER);
final String password = accessor.getFirstNativeHeader(PASSWORD_HEADER);
final UsernamePasswordAuthenticationToken user =
webSocketAuthenticatorService.getAuthenticatedOrFail(username, password);
accessor.setUser(user);
}
return message;
}
}
As mentioned earlier, for an API server using the OAuth 2.0 Resource library, only the username need be provided; password can be anything. Spring Boot Security simply authenticates the user id (from the websocket's header login) and accepts the connection.
The authentication is carried out in the WebSocketAuthenticatorService class:
/*
courtesy: Anthony Raymond: https://stackoverflow.com/questions/45405332/websocket-authentication-and-authorization-in-spring
*/
@Component
public class WebSocketAuthenticatorService {
public UsernamePasswordAuthenticationToken getAuthenticatedOrFail(final String username,
final String password)
throws AuthenticationException {
if (username == null || username.trim().isEmpty()) {
throw new AuthenticationCredentialsNotFoundException("Username was null or empty.");
}
if (password == null || password.trim().isEmpty()) {
throw new AuthenticationCredentialsNotFoundException("Password was null or empty.");
}
// You can add your own logic for retrieving user from, say, db
//if (fetchUserFromDb(username, password) == null) {
// throw new BadCredentialsException("Bad credentials for user " + username);
// Null credentials, we do not pass the password along
return new UsernamePasswordAuthenticationToken(
username,
null,
Collections.singleton((GrantedAuthority) () -> "USER") // MUST provide at least one role
);
}
}
The WebSocketAuthorizationSecurityConfig class extends AbstractSecurityWebSocketMessageBrokerConfigurer, allowing configuration of inbound messages:
@Configuration
public class WebSocketAuthorizationSecurityConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer {
@Override
protected void configureInbound(final MessageSecurityMetadataSourceRegistry messages) {
// You can customize your authorization mapping here.
messages.anyMessage().authenticated();
}
@Override
protected boolean sameOriginDisabled() {
return false;
}
}
Then we come to the WebSocketBrokerConfig class, which implements WebSocketMessageBrokerConfigurer:
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketBrokerConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/queue", "/topic");
registry.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOrigins("http://localhost:3000", "foo.com")
.withSockJS();
}
}
This in-memory broker is good for training and POCs. In production, however, a more robust provider like RabbitMQ or Kafka is called for.
Another implementation of the WebSocketMessageBrokerConfigurer is required for configuring the inbound channel. This is implemented in the class WebSocketSecurityConfig:
@Configuration
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class WebSocketSecurityConfig implements WebSocketMessageBrokerConfigurer {
@Autowired
private AuthChannelInterceptorAdaptor authChannelInterceptorAdapter;
@Override
public void registerStompEndpoints(final StompEndpointRegistry registry) {
// Endpoints are already registered on WebSocketConfig, no need to add more.
}
@Override
public void configureClientInboundChannel(final ChannelRegistration registration) {
registration.setInterceptors(authChannelInterceptorAdapter);
}
}
Note the @Order annotation, which configures a high precedence for the channel.
The React app client move from a ws-based websocket connection to an http-brokered one also requires a CORS filter to work. The example filter looks like:
/*
courtesy of https://stackoverflow.com/users/3669624/cнŝdk
*/
@Component
public class CORSFilter implements Filter {
private final List<String> allowedOrigins = Arrays.asList(
"http://localhost:3000", "foo.com");
public void destroy() {}
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
// Lets make sure that we are working with HTTP (that is, against HttpServletRequest and HttpServletResponse objects)
if (req instanceof HttpServletRequest && res instanceof HttpServletResponse) {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
// Access-Control-Allow-Origin
String origin = request.getHeader("Origin");
response.setHeader("Access-Control-Allow-Origin", allowedOrigins.contains(origin) ? origin : "");
response.setHeader("Vary", "Origin");
// Access-Control-Max-Age
response.setHeader("Access-Control-Max-Age", "3600");
// Access-Control-Allow-Credentials
response.setHeader("Access-Control-Allow-Credentials", "true");
// Access-Control-Allow-Methods
response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE");
// Access-Control-Allow-Headers
response.setHeader("Access-Control-Allow-Headers",
"Origin, X-Requested-With, Content-Type, Accept, " + "X-CSRF-TOKEN");
}
chain.doFilter(req, res);
}
public void init(FilterConfig filterConfig) {
}
}
The WebSocketController class simply provides an endpoint for the websocket's subscriptions and support for discrete '/queues'.
@Controller
public class WebSocketController {
@MessageMapping("/subscribe")
@SendToUser("/queue/notification")
public String replyToAccountFromClient(@Payload String message,
Principal user) {
return String.format("hello: %s", message);
}
@MessageExceptionHandler
@SendTo("/queue/errors")
public String handleException(Throwable exception) {
return exception.getMessage();
}
}
Finally, the HttpSecurity configuration looks like the following:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic().disable()
.formLogin().disable()
.addFilterAt(corsFilter, BasicAuthenticationFilter.class)
.csrf().ignoringAntMatchers(API_URL_PREFIX)
.and()
.cors()
.and()
.authorizeRequests()
.antMatchers("/ws/**").permitAll()
...
.oauth2ResourceServer(oauth2ResourceServer ->
oauth2ResourceServer.jwt(jwt ->
jwt.jwtAuthenticationConverter(
getJwtAuthenticationConverter())))
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}