djangographqlapollo-clientdjango-channelsdjango-graphql-jwt

Apollo client subscription pass JWT token handled by Django Channels middleware


I use Graphql subscriptions with Apollo client on a Vue3 app using Django graphQL Channels and DjangoGraphqlJWT packages in my backend app.

I'm trying to pass a JWT token on the Apollo subscriptions via the connectionParams.

Following this solution. I implemented a Middleware. However Apollo is passing the connectionParams as a payload. I can't find a way to access the payload at the Middleware level, but only on the consumer.

I could access the query string property from the scope argument in the middleware. However, I can't find a way to pass a query argument after the subscription is initiated.

CLIENT SIDE:

import { setContext } from "apollo-link-context";
import { Storage } from "@capacitor/storage";

import {
  ApolloClient,
  createHttpLink,
  InMemoryCache,
  split,
} from "@apollo/client/core";
import { getMainDefinition } from "@apollo/client/utilities";
import { WebSocketLink } from "@apollo/client/link/ws";

const authLink = setContext(async (_: any, { headers }: any) => {
  const { value: authStr } = await Storage.get({ key: "auth" });

  let token;
  if (authStr) {
    const auth = JSON.parse(authStr);
    token = auth.token;
  }

  // return the headers to the context so HTTP link can read them
  return {
    headers: {
      ...headers,
      authorization: token ? `JWT ${token}` : null,
    },
  };
});

const httpLink = createHttpLink({
  uri: process.env.VUE_APP_GRAPHQL_URL || "http://0.0.0.0:8000/graphql",
});

const wsLink = new WebSocketLink({
  uri: process.env.VUE_APP_WS_GRAPHQL_URL || "ws://0.0.0.0:8000/ws/graphql/",
  options: {
    reconnect: true,
    connectionParams: async () => {
      const { value: authStr } = await Storage.get({ key: "auth" });
      let token;
      if (authStr) {
        const auth = JSON.parse(authStr);
        token = auth.token;
        console.log(token); // So far so good the token is logged.
        return {
          token: token,
        };
      }

      return {};
    },
  },
});

const link = split(
  // split based on operation type
  ({ query }) => {
    const definition = getMainDefinition(query);
    return (
      definition.kind === "OperationDefinition" &&
      definition.operation === "subscription"
    );
  },
  wsLink,
  httpLink
);

const cache = new InMemoryCache();
export default new ApolloClient({
  // @ts-ignore
  link: authLink.concat(link),
  cache,
});

BACKEND:

asgy.py

from tinga.routing import MyGraphqlWsConsumer
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
from tinga.channels_middleware import JwtAuthMiddlewareStack
import os

from django.core.asgi import get_asgi_application
from django.conf.urls import url

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tinga.settings')

application = get_asgi_application()

# import websockets.routing

application = ProtocolTypeRouter({
    "http": get_asgi_application(),
    "websocket": JwtAuthMiddlewareStack(
        URLRouter([
            url(r"^ws/graphql/$", MyGraphqlWsConsumer.as_asgi()),
        ])
    ),
})

channels_middleware.py

@database_sync_to_async
def get_user(email):
    try:
        user = User.objects.get(email=email)
        return user

    except User.DoesNotExist:
        return AnonymousUser()


class JwtAuthMiddleware(BaseMiddleware):
    def __init__(self, inner):
        self.inner = inner

    async def __call__(self, scope, receive, send):
        # Close old database connections to prevent usage of timed out connections
        close_old_connections()

        # Either find a way to get the payload from Apollo in order to get the token.
        # OR
        # Pass pass the token in query string in apollo when subscription is initiated.
        # print(scope) # query_string, headers, etc.

        # Get the token
        # decoded_data = jwt_decode(payload['token'])

        # scope["user"] = await get_user(email=decoded_data['email'])
        return await super().__call__(scope, receive, send)


def JwtAuthMiddlewareStack(inner):
    return JwtAuthMiddleware(AuthMiddlewareStack(inner))

As far as I understand, I can only access query string / URL params in the Middleware and not the Apollo payload. Would it be possible to pass the token for now in the query string? However since the token might not exist when Apollo client is provided, it needs to be reevaluated like the connectionParams.

Any workaround?


Solution

  • I managed to get the token in the consumer payload and inject the user into the context.

    from tinga.schema import schema
    import channels_graphql_ws
    from channels.db import database_sync_to_async
    from django.contrib.auth.models import AnonymousUser
    from graphql_jwt.utils import jwt_decode
    from core.models import User
    from channels_graphql_ws.scope_as_context import ScopeAsContext
    
    
    @database_sync_to_async
    def get_user(email):
        try:
            user = User.objects.get(email=email)
            return user
    
        except User.DoesNotExist:
            return AnonymousUser()
    
    
    class MyGraphqlWsConsumer(channels_graphql_ws.GraphqlWsConsumer):
        """Channels WebSocket consumer which provides GraphQL API."""
        schema = schema
    
        # Uncomment to send keepalive message every 42 seconds.
        # send_keepalive_every = 42
    
        # Uncomment to process requests sequentially (useful for tests).
        # strict_ordering = True
    
        async def on_connect(self, payload):
            """New client connection handler."""
            # You can `raise` from here to reject the connection.
            print("New client connected!")
    
            # Create object-like context (like in `Query` or `Mutation`)
            # from the dict-like one provided by the Channels.
            context = ScopeAsContext(self.scope)
    
            if 'token' in payload:
                # Decode the token
                decoded_data = jwt_decode(payload['token'])
    
                # Inject the user
                context.user = await get_user(email=decoded_data['email'])
    
            else:
                context.user = AnonymousUser
    
    

    And then passing the token in the connectionParams

    const wsLink = new WebSocketLink({
      uri: process.env.VUE_APP_WS_GRAPHQL_URL || "ws://0.0.0.0:8000/ws/graphql/",
      options: {
        reconnect: true,
        connectionParams: async () => {
          const { value: authStr } = await Storage.get({ key: "auth" });
          let token;
          if (authStr) {
            const auth = JSON.parse(authStr);
            token = auth.token;
            console.log(token); // So far so good the token is logged.
            return {
              token: token,
            };
          }
    
          return {};
        },
      },
    });