Graphql-tools: Stitching secure subscriptions using makeRemoteExecutableSchema

Created on 25 Jun 2018  Â·  22Comments  Â·  Source: ardatan/graphql-tools

We have implemented schema stitching where GraphQL server fetches schema from two remote servers and stitches them together. Everything was working fine when we were only working with Query and Mutations, but now we have use-case where we even need to stitch Subscriptions and remote schema has auth implemented over it.

We are having hard time figuring out on how to pass authorization token received in connectionParams from client to remote server via gateway.

This is how we are introspecting schema:

API Gateway code:

const getLink = async(): Promise<ApolloLink> => {
    const http = new HttpLink({uri: process.env.GRAPHQL_ENDPOINT, fetch:fetch})

    const link = setContext((request, previousContext) => {
        if (previousContext
            && previousContext.graphqlContext
            && previousContext.graphqlContext.request
            && previousContext.graphqlContext.request.headers
            && previousContext.graphqlContext.request.headers.authorization) {
            const authorization = previousContext.graphqlContext.request.headers.authorization;
            return {
                headers: {
                    authorization
                }
            }
        }
        else {
            return {};
        }
    }).concat(http);


   const wsLink: any = new WebSocketLink(new SubscriptionClient(process.env.REMOTE_GRAPHQL_WS_ENDPOINT, {
        reconnect: true,
        // There is no way to update connectionParams dynamically without resetting connection
        // connectionParams: () => { 
        //     return { Authorization: wsAuthorization }
        // }
    }, ws));


    // Following does not work
       const wsLinkContext = setContext((request, previousContext) => {
        let authToken = previousContext.graphqlContext.connection && previousContext.graphqlContext.connection.context ? previousContext.graphqlContext.connection.context.Authorization : null
        return {
            context: {
                Authorization: authToken
            }
        }
    }).concat(<any>wsLink);

    const url = split(({query}) => {
        const {kind, operation} = <any>getMainDefinition(<any>query);
        return kind === 'OperationDefinition' && operation === 'subscription'
    },
    wsLinkContext,
    link)

    return url;
}

const getSchema =  async ():Promise<GraphQLSchema> => {
    const link = await getLink();
    return makeRemoteExecutableSchema({
            schema: await introspectSchema(link),
            link,
        });
}
const linkSchema = `
            extend type UserPayload {
                user: User
            }
            `;
const schema: any = mergeSchemas(
                {
                    schemas: [ linkSchema, getSchema],
                });
const server = new GraphQLServer({
                schema: schema,
                context: req => ({
                    ...req,
                })
            });

Is there any way for achieving this using graphql-tools? If not, can we create a feature request for same?

Most helpful comment

@josephktcheung Thanks for the gist. But for secured subscription, the connection is always hanging.
For insecure subscription (no need for headers), it worked.

All 22 comments

@nikhilkawtakwar did you make it work? If so, how?

@arcticbarra not actually. I connected directly to one of remote graphql server as I wanted subscription to work for that specific server.

Hi @nikhilkawtakwar, @arcticbarra,

I found a way to pass connectionParams from stitching server to remote server. Here's the gist link https://gist.github.com/josephktcheung/fa30b4db78f052fe4f8704794826a630.

The gist is:

  1. When constructing GraphQLServer, context callback has a connection param if it's a subscription, and connectionParams is nested inside connection.context
  const server = new GraphQLServer({
    schema,
    context: ({ connection }) => {
      if (connection && connection.context) {
        return connection.context;
      }
    }
  });
  1. Create a custom apollo ws link like below:
  const wsLink = (operation, forward) => {
    const context = operation.getContext();
    const connectionParams = context.graphqlContext || {};
    const client = new SubscriptionClient(subUri, {
      connectionParams,
      reconnect: true,
    }, ws);
    return client.request(operation);
  };

@josephktcheung
This will create new client and connection for each operation.
The SubscriptionClient could be created for each end client connection in connected lifecycle instead.
I'll create a gist with such solution in this week for review.

@mlewando I've taken your hint and created another gist: https://gist.github.com/josephktcheung/cd1b65b321736a520ae9d822ae5a951b

This time I use onConnect option in subscriptions of GraphQLServer and create a SubscriptionClient when a new client is connected. Is this what you mean?

That's exactly what I meant :)
But I'm not an expert in this area... I'd love to have an opinion of some more experienced guy about such design.

btw. I think that you're missing closing of SubscriptionClients in onDisconnect hook

btw2. If such approach is the correct one:

  • shouldn't it be documented somewhere? (I can try to write something about it, but it would need to know where to create some PR)
  • can we have some tool for that?

@mlewando updated the gist to close SubscriptionClient in onDisconnect hook

@mlewando

regarding your btw2, it can either be an article in https://www.apollographql.com/docs/graphql-subscriptions/ (which points to subscriptions-transport-ws github repo) or a section / an example in https://www.apollographql.com/docs/graphql-tools/schema-stitching.html (which is graphql-tools repo). Perhaps maintainers / members of this repo can help decide where the documentation should be located. My preference is

Also, what do you mean by tool?

Something that would encapsulate creation/closing of the clients and the custom wsLink implementation. Eg. Additional class in apollo-link-ws that you would also pass to subscriptions argument of server.start.
IMO passing connection params via proxy server to the remote schema is rather a common scenario... (or not?). It schould not require the developer to write such code every time...

@josephktcheung Thanks for the gist. But for secured subscription, the connection is always hanging.
For insecure subscription (no need for headers), it worked.

Any updates?

For anyone that's still struggling this is how I ended up solving it. I was using AbsintheSocket but should be pretty similar for a normal socket connection. https://gist.github.com/arcticbarra/b3d6557f86472c48ae62f82f85e0dc8e

Hi,
Have the same issue, here is my PR to solve this (https://github.com/apollographql/subscriptions-transport-ws/pull/452) in the subscription-transport-ws repo. But it's been over a year and still no reply. Did you solve this? Can someone approve this PR?

My take using token/service name as identifiers to reuse the link: https://gist.github.com/tomasAlabes/a8f160d8aeb807976819ea413bcd9c5b

This is a working example of remote schema with subscription by webscoket and query and mutation by http. It can be secured by custom headers(params) and shown in this example.

Flow

Client request
-> context is created by reading req or connection(jwt is decoded and create user object in the context)
-> remote schema is executed
-> link is called
-> link is splitted by operation(wsLink for subscription, httpLink for queries and mutations)
-> wsLink or httpLink access to context created above (=graphqlContext)
-> wsLink or httpLink use context to created headers(authorization header with signed jwt in this example) for remote schema.
-> "subscription" or "query or mutation" are forwarded to remote server.

Note

  1. Currently, ContextLink does not have any effect on WebsocketLink. So, instead of concat, we should create raw ApolloLink.
  2. When creating context, checkout connection, not only req. The former will be available if the request is websocket, and it contains meta information user sends, like an auth token.
  3. HttpLink expects global fetch with standard spec. Thus, do not use node-fetch, whose spec is incompatible (especially with typescript). Instead, use cross-fetch.
const wsLink = new ApolloLink(operation => {
    // This is your context!
    const context = operation.getContext().graphqlContext

    // Create a new websocket link per request
    return new WebSocketLink({
      uri: "<YOUR_URI>",
      options: {
        reconnect: true,
        connectionParams: { // give custom params to your websocket backend (e.g. to handle auth) 
          headers: {
            authorization: jwt.sign(context.user, process.env.SUPER_SECRET),
            foo: 'bar'
          }
        },
      },
      webSocketImpl: ws,
    }).request(operation)
    // Instead of using `forward()` of Apollo link, we directly use websocketLink's request method
  })

const httpLink = setContext((_graphqlRequest, { graphqlContext }) => {
  return {
    headers: {
      authorization: jwt.sign(graphqlContext.user, process.env.SUPER_SECRET),
    },
  }
}).concat(new HttpLink({
  uri,
  fetch,
}))

const link = split(
  operation => {
    const definition = getMainDefinition(operation.query)
    return (
      definition.kind === 'OperationDefinition' &&
      definition.operation === 'subscription'
    )
  },
  wsLink, // <-- Executed if above function returns true
  httpLink, // <-- Executed if above function returns false
)

const schema = await introspectSchema(link)

const executableSchema = makeRemoteExecutableSchema({
    schema,
    link,
  })

const server = new ApolloServer({
  schema: mergeSchemas({ schemas: [ executableSchema, /* ...anotherschemas */] }),
  context: ({ req, connection }) => {
    let authorization;
    if (req) { // when query or mutation is requested by http
      authorization = req.headers.authorization
    } else if (connection) { // when subscription is requested by websocket
      authorization = connection.context.authorization
    }
    const token = authorization.replace('Bearer ', '')
    return {
      user: getUserFromToken(token),
    }
  },
})

Folding into #1316

Reopening this issue to track progress on an example demonstrating existing functionality and documenting any gaps.

Is this really a documentation issue?

See: https://github.com/ardatan/graphql-tools/issues/864#issuecomment-580668295

Replace mergeSchemas with stitchSchemas and makeRemoteExecutableSchema with wrapSchema and link with executor via linkToExecutor.

I am under the assumption that the flow works just everyone would be better off with a canonical example within the repository.

I just tried this code and it doesn't seem to be working. This is my current code:

import fetch from 'cross-fetch'
import { introspectSchema, makeRemoteExecutableSchema } from 'apollo-server'
import { HttpLink } from 'apollo-link-http'
import { WebSocketLink } from 'apollo-link-ws'
import { ApolloLink, split } from 'apollo-link'
import { setContext } from 'apollo-link-context'
import { getMainDefinition } from 'apollo-utilities'
import ws from 'ws'

export const getRemoteSchema = async ({ uri, subscriptionsUri }) => {
  const wsLink = new ApolloLink((operation) => {
    // This is your context!
    const context = operation.getContext().graphqlContext

    // Create a new websocket link per request
    return new WebSocketLink({
      uri: subscriptionsUri,
      options: {
        reconnect: true,
        connectionParams: { // give custom params to your websocket backend (e.g. to handle auth)
          headers: { authorization: context?.authorization || null },
        },
      },
      webSocketImpl: ws,
    }).request(operation)
    // Instead of using `forward()` of Apollo link, we directly use websocketLink's request method
  })

  const httpLink = setContext((_graphqlRequest, { graphqlContext }) => {
    return {
      headers: { authorization: graphqlContext?.authorization || null },
    }
  }).concat(new HttpLink({
    uri,
    fetch,
  }))

  const link = split(
    (operation) => {
      const definition = getMainDefinition(operation.query)
      return definition.kind === 'OperationDefinition' && definition.operation === 'subscription'
    },
    wsLink, // <-- Executed if above function returns true
    httpLink // <-- Executed if above function returns false
  )

  const schema = await introspectSchema(link)
  const executableSchema = makeRemoteExecutableSchema({ schema, link })
  return executableSchema
}

I'm getting

ServerParseError: Unexpected token u in JSON at position 0
    at JSON.parse (<anonymous>)
    at <redacted>/node_modules/apollo-link-http-common/src/index.ts:131:23
    at process._tickCallback (internal/process/next_tick.js:68:7)

This seems to be coming from the call to .concat(). When I remove that, I get

TypeError: forward is not a function
    at <redacted>/node_modules/apollo-link-context/src/index.ts:24:20
    at process._tickCallback (internal/process/next_tick.js:68:7)
    at Function.Module.runMain (internal/modules/cjs/loader.js:757:11)
    at main (<redacted>/node_modules/ts-node/src/bin.ts:227:14)
    at Object.<anonymous> (<redacted>/node_modules/ts-node/src/bin.ts:513:3)
    at Module._compile (internal/modules/cjs/loader.js:701:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:712:10)
    at Module.load (internal/modules/cjs/loader.js:600:32)
    at tryModuleLoad (internal/modules/cjs/loader.js:539:12)
    at Function.Module._load (internal/modules/cjs/loader.js:531:3)

And when I remove the httpLink completely (and instead passing wsLink twice to split(), the system just hangs at the new WebSocketLink({...}).request(operation) call.

Well, I don't know what was wrong with my environment, but the above code works now ¯\_(ツ)_/¯

Closing. It would be nice to have an examples directory with complex examples like this and caching, etc, but for now i'll close to keep the issues tracker clean.

Was this page helpful?
0 / 5 - 0 ratings