-
Notifications
You must be signed in to change notification settings - Fork 312
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
Comments
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 The suggested way to authenticate with apollo, for example, is to use the init message and transfer the token as the payload: But there is no counterpart to that on the server side. I've been digging around in the In general, a
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. But: in 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 |
Expose information about the WebSocketSession consistently in all methods of WebSocketGraphQlInterceptor. See gh-268
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 This allows you to correlate the session between the initial 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. |
@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 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
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 |
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 {
"Authorization": "Bearer <access token>"
} as part of NOTE: @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 @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 /**
* 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 Hope it helps others too. |
Hi all! UPD: I permitted handshake request to my endpoint in 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. |
See gh-268 Co-authored-by: Josh Cummings <josh.cummings@gmail.com>
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.
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
The text was updated successfully, but these errors were encountered: