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

Stitching secure subscriptions using makeRemoteExecutableSchema #864

Closed
nikhilkawtakwar opened this issue Jun 25, 2018 · 22 comments
Closed

Comments

@nikhilkawtakwar
Copy link

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?

@enriquebrgn
Copy link

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

@nikhilkawtakwar
Copy link
Author

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

@josephktcheung
Copy link

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

@mlewando
Copy link

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

@josephktcheung
Copy link

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

@mlewando
Copy link

mlewando commented Sep 16, 2018

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?

@josephktcheung
Copy link

josephktcheung commented Sep 16, 2018

@mlewando updated the gist to close SubscriptionClient in onDisconnect hook

@josephktcheung
Copy link

josephktcheung commented Sep 16, 2018

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

@mlewando
Copy link

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

@revskill10
Copy link

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

@maxpain
Copy link

maxpain commented Nov 6, 2018

Any updates?

@enriquebrgn
Copy link

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

@bikov
Copy link

bikov commented Oct 22, 2019

Hi,
Have the same issue, here is my PR to solve this (apollographql/subscriptions-transport-ws#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?

@tomasAlabes
Copy link

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

@jjangga0214
Copy link

jjangga0214 commented Jan 31, 2020

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

@yaacovCR
Copy link
Collaborator

yaacovCR commented Apr 1, 2020

Folding into #1316

@yaacovCR yaacovCR closed this as completed Apr 1, 2020
@yaacovCR yaacovCR mentioned this issue Apr 1, 2020
10 tasks
@yaacovCR
Copy link
Collaborator

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

@yaacovCR yaacovCR reopened this May 13, 2020
@flux627
Copy link

flux627 commented Jun 18, 2020

Is this really a documentation issue?

@yaacovCR
Copy link
Collaborator

See: #864 (comment)

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.

@flux627
Copy link

flux627 commented Jun 24, 2020

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.

@flux627
Copy link

flux627 commented Jun 27, 2020

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

@yaacovCR
Copy link
Collaborator

yaacovCR commented Oct 6, 2020

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.

@yaacovCR yaacovCR closed this as completed Oct 6, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests