Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support GraphQL over WebSocket authentication via "connect_init" message #268

Closed
fschmuck opened this issue Jan 24, 2022 · 12 comments
Closed
Assignees
Labels
type: enhancement A general enhancement
Milestone

Comments

@fschmuck
Copy link

fschmuck commented Jan 24, 2022

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

@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged label Jan 24, 2022
@rstoyanchev
Copy link
Contributor

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.

@fernanfs
Copy link

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 a SecurityContext and stored for the lifetime of the websocket connection. (No idea where to start to implement that. AuthenticationWebFilter might be the right place to look at?)
  • intercept:
    every incoming call should be wrapped with the appropriate SecurityContext. This might be very similar to ReactorContextWebFilter, but here I'm not sure at all.
  • handleConnectionClosed:
    here the stored SecurityContext should 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.

@fernanfs
Copy link

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.

rstoyanchev added a commit that referenced this issue Apr 19, 2022
Expose information about the WebSocketSession consistently in all
methods of WebSocketGraphQlInterceptor.

See gh-268
@rstoyanchev
Copy link
Contributor

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.

@rstoyanchev rstoyanchev added type: enhancement A general enhancement and removed status: waiting-for-triage An issue we've not yet triaged labels Apr 19, 2022
@rstoyanchev rstoyanchev added this to the 1.0.0 milestone Apr 19, 2022
@rstoyanchev rstoyanchev changed the title Secure websocket endpoint Support authentication via GraphQL over WebSocket "connect_init" message payload Apr 19, 2022
@fernanfs
Copy link

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.

@rstoyanchev
Copy link
Contributor

rstoyanchev commented Apr 25, 2022

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.

@rwinch
Copy link
Member

rwinch commented Apr 26, 2022

@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.

@rstoyanchev
Copy link
Contributor

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.

@rstoyanchev rstoyanchev modified the milestones: 1.0.0, 1.0 Backlog May 16, 2022
@Munoon
Copy link
Contributor

Munoon commented May 29, 2022

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() }
    }
}

@fernanfs
Copy link

fernanfs commented Nov 23, 2022

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.

@rstoyanchev rstoyanchev modified the milestones: 1.2 Backlog, 1.x Backlog Apr 27, 2023
@rstoyanchev rstoyanchev modified the milestones: 1.x Backlog, 1.3 Backlog May 15, 2023
@thekalinga
Copy link

thekalinga commented Jun 4, 2023

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.

@viacheslav-dobrynin
Copy link

viacheslav-dobrynin commented Oct 2, 2023

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
image

Maybe this will be useful to someone.
However, I don’t know how true this is, since I haven’t found any standard approach.

@rstoyanchev rstoyanchev modified the milestones: 1.3 Backlog, 1.3.0 Apr 16, 2024
@rstoyanchev rstoyanchev changed the title Support authentication via GraphQL over WebSocket "connect_init" message payload Support GraphQL over WebSocket authentication via "connect_init" message Apr 16, 2024
rstoyanchev added a commit that referenced this issue May 17, 2024
See gh-268

Co-authored-by: Josh Cummings <josh.cummings@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type: enhancement A general enhancement
Projects
Status: No status
Development

No branches or pull requests

8 participants