Support authentication via GraphQL over WebSocket "connect_init" message payload
I implemented my spring-graphql application according to the samples in the repository. The client is a apollo-angular application which receives the jwt from a separate keycloak server. When the client establishes the websocket connection, it sends the jwt in the payload of the connect_init message as described in the graphql-ws documentation.
INIT MESSAGE FROM APOLLO CLIENT: `{"type":"connection_init","payload":{"Authorization":"Bearer <VALID_TOKEN>"}}`
ACK MESSAGE: `{"type":"connection_ack","payload":{}}`
SUBSCRIBE: `{"id":"06b0c701-bf03-4630-a32e-6e3b4da513df","type":"subscribe", <GRAPHQL_SUBSCRIPTION>}`
ERROR: `{"type":"error","payload":[{"message":"An Authentication object was not found in the SecurityContext","locations":[],"extensions":{"classification":"DataFetchingException"}}],"id":"<ID>"}`
The subscription method in the controller is annotated with @PreAuthorize("isAuthenticated()").
I use spring-boot-starter-oauth2-resource-server to validate the jwt against the keyset of the keycloak server.
I do not understand how to validate this jwt and populate the SecurityContext with data. Every sample out there seems to use STOMP endpoints. Could somebody explain to me how to handle this properly?
Thank you
It should just work, the context from the websocket handshake should propagate to your controller method but we might be missing something. If you have a repro handy, please do provide it, or otherwise we'll put one together. There is an issue to improve the samples.
I'm running into the same problem. Although, I'm using the webflux variant.
After digging around in the codebase, there doesn't seem to be any support for authentication based on the payload of the init message. It might work, if the Authorization header was specified in http request that initiated the connection upgrade, but the WebSocket API on the client (ES/JS) doesn't provide any means (by default) to provide the header. Thus, providing the token using the HTTP header is not the best way to achieve that.
The suggested way to authenticate with apollo, for example, is to use the init message and transfer the token as the payload: https://www.apollographql.com/docs/react/data/subscriptions/#5-authenticate-over-websocket-optional The appropriate API is documented in the link above.
But there is no counterpart to that on the server side. I've been digging around in the spring-graphql codebase and found, that the implementation of a custom WebSocketGraphQlHandlerInterceptor could solve that issue. Unfortunately, I don't know exactly where to start.
In general, a WebSocketGraphQlHandlerInterceptor implementing the methods as follows:
-
handleConnectionInitialization: on every new connection, inspect the payload and extract the token. This token should then be converted to aSecurityContextand stored for the lifetime of the websocket connection. (No idea where to start to implement that.AuthenticationWebFiltermight be the right place to look at?) -
intercept: every incoming call should be wrapped with the appropriateSecurityContext. This might be very similar toReactorContextWebFilter, but here I'm not sure at all. -
handleConnectionClosed: here the storedSecurityContextshould be removed
If anyone could tell me, whether or not that would be a feasible approach, I'm more than happy to give the implementation a shot.
In addition, I'm willing to provide a sample repository. Any suggestions on how to mock an openid authentication system which ideally runs embedded in SpringBoot? With such an embedded "small footprint" authentication system, it would be easy to provide a truly self contained example.
Well, I tried to implement something as outlined in my previous post. Unfortunately, I failed.
I was able to access and decode the Jwt Token I use in the handleConnectionInitialization. I essentially reused the whole authentication logic, that is used with the oauth resource server integration. Once the Authentication object has been created, I store that object in a map, associated with the websocket session id.
But: in intercept there is now way for me to associate the incoming request with the websocket session. Thus, I can't determine which authentication I should use.
To be honest, this really seems to be a serious flaw in the current implementation and API design. Even with some listeners (or interceptors) in place, with different or extended APIs, you would still need to reimplement the validation logic, that has already been configured through spring security. But that configured logic, can not be reused, as it is tailored for the ServerWebExchange and explicitly bound to that. And there is no ServerWebExchange when the token is sent as a part of the connection init message.
Thanks @fernanfs for elaborating and apologies for the confusion. I had misread the original report, not realizing it's about authentication through the "connection_init" message payload, which is not something we support at the moment.
As an initial step, I've updated methods on WebSocketGraphQlInterceptor to have a WebSocketSessionInfo argument instead of the WebSocketSession id. Likewise, the intercept method can access the same by downcasting the request to WebSocketGraphQlRequest.
This allows you to correlate the session between the initial handleConnectionInitialization and subsequent intercept methods for each request on the session, and you can also store data via WebSocketSession#getAttributes. This should make it possible to complete the experiment you described above.
I realize we need to support this as a first class option though, by providing a GraphQL over WebSocket interceptor for Spring Security. I'll schedule to explore that possibility for 1.0.
That is great news @rstoyanchev, thanks for adding that to the codebase! I'll give that a shot as soon as possible.
Supporting authentication through the connection_init message will indeed be challenging. But not actually on the GraphQL side - rather more on the spring security side. Spring Security and the appropriate configuration and object instances arising from that configuration, are tightly coupled to the ServerWebExchange. This poses a problem, as there is no ServerWebExchange in case of the WebSocket messages. I had no other chance than to recompose a hierarchy of objects to perform the validation of my JWT token, essentially duplicating what I configured for spring security oauth resource server.
We'll certainly want to provide built-in support for this but in the mean time, not sure @rwinch or @jzheaux if you have any further advice regarding @fernanfs's last comment.
@fernanfs You are right that if you want to support authenticating over HTTP and WebSocket you will need to provide configuration for both options. Since there is no support for WebSocket, you will need to manually configure that. The DSL provides wiring for the controllers which are written as WebFilter instances. These are coupled to ServerWebExchange. It also wires up ReactiveJwtDecoder which is not coupled to ServerWebExchange. You can reuse the ReactiveJwtDecoder in the web socket code but you will need to obtain the JWT and pass it into the ReactiveJwtDecoder.
We've discussed this. While it's too late for 1.0, we can provide samples soon to use as a workaround until fully integrated in an upcoming follow-up release.
It might be useful for some one. I created the following interceptor to solve this issue:
class WebSocketAuthenticationInterceptor(
private val jwtDecoder: ReactiveJwtDecoder,
) : WebSocketGraphQlInterceptor {
private companion object {
const val TOKEN_KEY_NAME = "token"
const val TOKEN_PREFIX = "Bearer "
private val AUTHENTICATION_SESSION_ATTRIBUTE_KEY =
WebSocketAuthenticationInterceptor::class.qualifiedName + ".authentication"
fun WebSocketSessionInfo.getAuthentication(): CustomJwtAuthenticationToken? =
attributes[AUTHENTICATION_SESSION_ATTRIBUTE_KEY] as? CustomJwtAuthenticationToken
fun WebSocketSessionInfo.setAuthentication(authentication: CustomJwtAuthenticationToken) {
attributes[AUTHENTICATION_SESSION_ATTRIBUTE_KEY] = authentication
}
}
override fun intercept(request: WebGraphQlRequest, chain: WebGraphQlInterceptor.Chain): Mono<WebGraphQlResponse> {
val authentication = (request as? WebSocketGraphQlRequest)?.sessionInfo?.getAuthentication()
?: return chain.next(request)
return chain.next(request)
.contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication))
}
override fun handleConnectionInitialization(
sessionInfo: WebSocketSessionInfo,
connectionInitPayload: MutableMap<String, Any>,
): Mono<Any> {
val jwtToken = (connectionInitPayload[TOKEN_KEY_NAME] as? String)
?.takeIf { it.startsWith(TOKEN_PREFIX, ignoreCase = true) }
?.substring(TOKEN_PREFIX.length)
?: return Mono.empty()
return jwtDecoder.decode(jwtToken)
.map { CustomJwtAuthenticationToken(it) }
.doOnNext { sessionInfo.setAuthentication(it) }
.flatMap { Mono.empty() }
}
}
Based on your example @Munoon, I've created an alternative one, that reuses the AuthenticationManager logic:
class WebSocketAuthenticationInterceptor(private val authenticationManager: ReactiveAuthenticationManager): WebSocketGraphQlInterceptor {
private companion object {
const val TOKEN_KEY_NAME = "token"
private val AUTHENTICATION_SESSION_ATTRIBUTE_KEY =
WebSocketAuthenticationInterceptor::class.qualifiedName + ".authentication"
fun WebSocketSessionInfo.getAuthentication(): BearerTokenAuthenticationToken? =
attributes[AUTHENTICATION_SESSION_ATTRIBUTE_KEY] as? BearerTokenAuthenticationToken
fun WebSocketSessionInfo.setAuthentication(authentication: BearerTokenAuthenticationToken) {
attributes[AUTHENTICATION_SESSION_ATTRIBUTE_KEY] = authentication
}
}
override fun intercept(request: WebGraphQlRequest, chain: WebGraphQlInterceptor.Chain): Mono<WebGraphQlResponse> {
if (request !is WebSocketGraphQlRequest) {
return chain.next(request)
}
val securityContext = Mono.just(request)
.ofType<WebSocketGraphQlRequest>()
.mapNotNull { it.sessionInfo.getAuthentication() }
.flatMap { authenticationManager.authenticate(it) }
.map { SecurityContextImpl(it) }
return chain.next(request)
.contextWrite(ReactiveSecurityContextHolder.withSecurityContext(securityContext))
}
override fun handleConnectionInitialization(
sessionInfo: WebSocketSessionInfo,
connectionInitPayload: MutableMap<String, Any>,
): Mono<Any> {
val token = connectionInitPayload[TOKEN_KEY_NAME] as? String
if (token != null) {
sessionInfo.setAuthentication(BearerTokenAuthenticationToken(token))
}
return Mono.empty()
}
}
When using the Spring Boot OAuth Resource Server integration, the following @Bean will reuse the configured jwt-issuer:
@Bean
fun graphqlWsInterceptor(
@Value("\${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
issuerUri: String
) = WebSocketAuthenticationInterceptor(
JwtReactiveAuthenticationManager(ReactiveJwtDecoders.fromIssuerLocation(issuerUri))
)
With the code above, it is possible to use the normal Spring Security integration with Spring Boot. For example injecting the Principal in Controllers or using the @PreAuthorize Annotation.
I created a Java version of this implementation (with slight modifications) to clear authentication session attribute on close/cancel (I'm not sure if framework does this on its own or not, but I am clearing it just to be on the safe side).
This expects the graphql client to send payload of
{
"Authorization": "Bearer <access token>"
}
as part of connection_init message. Reason why the key inside json is not named token (or) lowercase authorization is because graphiql client of spring (web browser based client for exploring graphql APIs) passes everything under Headers section as both connection_init payload incase of graphql subscriptions & as HTTP header in case of queries. By keeping the format same as HTTP header i.e Authorization: Bearer <access token>, we can ensure web have similar headers are being sent across both transport HTTP & Websocket.
NOTE: val is from lombok, which is equivalent to final var.
@Component
public class JwtBearerTokenAuthenticatingWebSocketGraphQlInterceptor implements WebSocketGraphQlInterceptor {
private static final String AUTHORIZATION_CONNECTION_INIT_PAYLOAD_KEY_NAME = "Authorization";
private static final String AUTHORIZATION_CONNECTION_INIT_PAYLOAD_VALUE_PREFIX = "Bearer ";
private static final String AUTHENTICATION_SESSION_ATTRIBUTE_KEY = JwtBearerTokenAuthenticatingWebSocketGraphQlInterceptor.class.getCanonicalName() + ".authentication";
private final ReactiveAuthenticationManager authenticationManager;
public JwtBearerTokenAuthenticatingWebSocketGraphQlInterceptor(ReactiveAuthenticationManager authenticationManager) {
this.authenticationManager = authenticationManager;
}
@Override
@NotNull
public Mono<Object> handleConnectionInitialization(@NotNull WebSocketSessionInfo sessionInfo,
Map<String, Object> connectionInitPayload) {
var authorizationHeaderValue = (String) connectionInitPayload.get(AUTHORIZATION_CONNECTION_INIT_PAYLOAD_KEY_NAME);
if (authorizationHeaderValue != null) {
if (authorizationHeaderValue.startsWith(AUTHORIZATION_CONNECTION_INIT_PAYLOAD_VALUE_PREFIX)) {
val accessToken = authorizationHeaderValue.substring(AUTHORIZATION_CONNECTION_INIT_PAYLOAD_VALUE_PREFIX.length());
if (StringUtils.hasText(accessToken)) {
setAuthentication(sessionInfo, new BearerTokenAuthenticationToken(accessToken));
}
}
}
// Nothing to send as part of the `connect_ack` response. So, lets return empty
return empty();
}
@Override
@NotNull
public Mono<WebGraphQlResponse> intercept(@NotNull WebGraphQlRequest request, @NotNull Chain chain) {
if (!(request instanceof WebSocketGraphQlRequest)) {
return chain.next(request);
}
val securityContext$ = just(request)
.ofType(WebSocketGraphQlRequest.class)
.mapNotNull(webSocketGraphQlRequest -> getAuthentication(webSocketGraphQlRequest.getSessionInfo()))
.flatMap(authenticationManager::authenticate)
.map(SecurityContextImpl::new);
return chain.next(request)
.contextWrite(withSecurityContext(securityContext$));
}
@Override
@NotNull
public Mono<Void> handleCancelledSubscription(@NotNull WebSocketSessionInfo sessionInfo, @NotNull String subscriptionId) {
// lets clear Authn if client cancels it
clearAuthentication(sessionInfo);
return empty();
}
@Override
public void handleConnectionClosed(@NotNull WebSocketSessionInfo sessionInfo,
int statusCode, @NotNull Map<String, Object> connectionInitPayload) {
// lets clear Authn if connection is closed
clearAuthentication(sessionInfo);
}
@Nullable
private BearerTokenAuthenticationToken getAuthentication(WebSocketSessionInfo webSocketSessionInfo) {
return (BearerTokenAuthenticationToken) webSocketSessionInfo.getAttributes().get(AUTHENTICATION_SESSION_ATTRIBUTE_KEY);
}
private void setAuthentication(WebSocketSessionInfo webSocketSessionInfo, BearerTokenAuthenticationToken authentication) {
webSocketSessionInfo.getAttributes().put(AUTHENTICATION_SESSION_ATTRIBUTE_KEY, authentication);
}
private void clearAuthentication(WebSocketSessionInfo webSocketSessionInfo) {
webSocketSessionInfo.getAttributes().remove(AUTHENTICATION_SESSION_ATTRIBUTE_KEY);
}
}
And corresponding dependency being injected via @Configuration bean
@Bean
ReactiveAuthenticationManager authenticationManager(OAuth2ResourceServerProperties resourceServerProperties) {
val jwtDecoder = fromIssuerLocation(resourceServerProperties.getJwt().getIssuerUri());
return new JwtReactiveAuthenticationManager(jwtDecoder);
}
Also, I added the following exception handlers for handling subscriptions so both Data fetcher security exceptions & subscription security exceptions are handled in the exact same manner
/**
* Resolves subscription errors & keeps exception handling consistent with {@link
* ReactiveSecurityDataFetcherExceptionResolver}
*/
@Component
public class ReactiveSecuritySubscriptionExceptionResolver implements SubscriptionExceptionResolver {
private final AuthenticationTrustResolver trustResolver = new AuthenticationTrustResolverImpl();
@Override
@NotNull
public Mono<List<GraphQLError>> resolveException(@NotNull Throwable exception) {
if (exception instanceof AuthenticationException) {
val error = resolveUnauthorized();
return just(singletonList(error));
}
if (exception instanceof AccessDeniedException) {
return ReactiveSecurityContextHolder.getContext()
.map(context -> singletonList(resolveAccessDenied(trustResolver, context)))
.switchIfEmpty(fromCallable(() -> singletonList(resolveUnauthorized())));
}
// Unknown exception type. Let someone else handle this exception
return Mono.empty();
}
}
The methods resolveUnauthorized() & resolveAccessDenied(..) are defined in
/**
* Keeps exception handling consistent with {@link
* org.springframework.graphql.execution.SecurityExceptionResolverUtils}
*/
@UtilityClass
class SecurityExceptionResolverUtils {
static GraphQLError resolveUnauthorized() {
return GraphqlErrorBuilder.newError()
.errorType(ErrorType.UNAUTHORIZED)
.message("Unauthorized")
.build();
}
static GraphQLError resolveAccessDenied(
AuthenticationTrustResolver resolver, SecurityContext securityContext) {
return resolver.isAnonymous(securityContext.getAuthentication())
? resolveUnauthorized()
: GraphqlErrorBuilder.newError()
.errorType(ErrorType.FORBIDDEN)
.message("Forbidden")
.build();
}
}
Now @SubscriptionMapping(..) (or) @QueryMapping(..) can be secured using @PreAuthorize & @PostAuthorize such as @PreAuthorize("hasAuthority('role_user')") or @PreAuthorize("hasAnyAuthority('role_admin', 'role_user')") assuming the SecurityConfiguration i.e @Configuration has @EnableWebFluxSecurity & @EnableReactiveMethodSecurity enabled.
Hope it helps others too.
Hi all!
I did everything the same as you, but my program does not reach the execution of methods in the WebSocketGraphQlInterceptor, because I receive the following log messages (on debug level): AuthorizationWebFilter: Authorization failed: Access Denied -> WebSessionServerSecurityContextRepository : No SecurityContext found in WebSession -> HttpWebHandlerAdapter: Completed 403 FORBIDDEN.
@Munoon, @fernanfs, @thekalinga do you have any idea why this is so?
UPD: I permitted handshake request to my endpoint in SecurityConfig as stated in the link: https://stackoverflow.com/questions/45405332/websocket-authentication-and-authorization-in-spring.
Also I return an error if there is no token or there are other issues rather than empty, in which case the requests are not authorized:
override fun handleConnectionInitialization(
sessionInfo: WebSocketSessionInfo,
connectionInitPayload: Map<String, Any>
): Mono<Any> {
val token = connectionInitPayload[TOKEN_KEY_NAME]?.toString()
?: return Mono.error(IllegalStateException("Token not found"))
// rest of the code
Maybe this will be useful to someone. However, I don’t know how true this is, since I haven’t found any standard approach.