apollographql / apollo-client

:rocket:  A fully-featured, production ready caching GraphQL client for every UI framework and GraphQL server.
https://apollographql.com/client
MIT License
19.38k stars 2.66k forks source link

It is not possible to pass Authorization header to WebSocket #3967

Closed hudymi closed 6 years ago

hudymi commented 6 years ago

Intended outcome:

Since Apollo Client 2 it is not possible to pass custom HTTP Header to WebSocket connection. In Apollo Client 1 it was possible by Middleware, but since version 2 it is not. I tried with additional link concat, or by applying Middleware to subscriptionClient.

Sample with authorized link:

  const httpLink = createHttpLink({ uri: `https://ws.server.local/graphql` });
  const wsLink = new WebSocketLink({
    uri: `wss://ws.server.local/graphql`,
    options: {
      reconnect: true,
    },
  });

  const middlewareLink = new ApolloLink((operation, forward) => {
    operation.setContext({
      headers: {
        authorization: getBearerToken() || null,
      },
    });
    return forward(operation);
  });

  const authorizedLink = middlewareLink.concat(wsLink);

  const link = split(
    ({ query }) => {
      const { kind, operation } = getMainDefinition(query);
      return kind === 'OperationDefinition' && operation === 'subscription';
    },
    authorizedLink,
    httpLink,
  );

  const client = new ApolloClient({
    link: link,
    ...
  });

Sample with subscriptionClient Middleware

  const httpLink = createHttpLink({ uri: `https://ws.server.local/graphql` });
  const wsLink = new WebSocketLink({
    uri: `wss://ws.server.local/graphql`,
    options: {
      reconnect: true,
    },
  });

  const subscriptionMiddleware = {
    applyMiddleware(options, next) {
      console.log(options);
      options.setContext({
        headers: {
          authorization: getBearerToken() || null,
        },
      });
      next();
    },
  };

  wsLink.subscriptionClient.use([subscriptionMiddleware]);

  const link = split(
    ({ query }) => {
      const { kind, operation } = getMainDefinition(query);
      return kind === 'OperationDefinition' && operation === 'subscription';
    },
    wsLink,
    httpLink,
  );

  const client = new ApolloClient({
    link: link,
    ...
  });

Versions

  System:
    OS: macOS High Sierra 10.13.6
  Binaries:
    Node: 9.5.0 - /usr/local/bin/node
    npm: 5.6.0 - /usr/local/bin/npm
  Browsers:
    Chrome: 69.0.3497.100
    Firefox: 60.0.2
    Safari: 12.0
  npmPackages:
    apollo-boost: ^0.1.3 => 0.1.15 
    apollo-client: ^2.4.2 => 2.4.2 
    apollo-link-ws: ^1.0.9 => 1.0.9 
    react-apollo: ^2.0.4 => 2.1.11 
coco98 commented 6 years ago

@michal-hudy Does this not work for you?


const wsLink = new WebSocketLink(
  new SubscriptionClient(WS_URL, {
    reconnect: true,
    timeout: 30000,
    connectionParams: {
      headers: {
        Authorization: "Bearer xxxxx"
      }
    }
  })
);
pkosiec commented 6 years ago

It won't work as WebSocket API in browsers doesn't support setting custom headers, apart from the value of Sec-Websocket-Protocol header. https://stackoverflow.com/questions/4361173/http-headers-in-websockets-client-api/41521871

hudymi commented 6 years ago

Please see @pkosiec comment.

pyankoff commented 5 years ago

Struggled with adding async function for connection params, was getting start received before the connection is initialised error. Fixed it by adding lazy: true to connection options:

const wsLink = new WebSocketLink({
  uri: WS_URL,
  options: {
    lazy: true,
    reconnect: true,
    connectionParams: async () => {
      const token = await getToken();
      return {
        headers: {
          Authorization: token ? `Bearer ${token}` : "",
        },
      }
    },
  },
})

Just in case someone having the same issue.

georgyfarniev commented 5 years ago

@pyankoff how can I read this header on server side?

pyankoff commented 5 years ago

@georgyfarniev I think it depends on your server side implementation. I was using Hasura and didn't have to handle it.

LermanR commented 4 years ago

@pyankoff I'm using Hasura as well. how did you handle token change (refresh after expiration) with WebSocketLink?

dreamer01 commented 4 years ago

Hey, @pyankoff can you please share how you were able to re-authenticate the user once the token has expired. I am able to get new accessToken using refreshToken but I am failing to pass the new accessToken to client. I have read through few examples and all direct towards using operation.setContext , which I was unable to implement while using WebSocketLink.

Thanks.

LermanR commented 4 years ago

@dreamer01 If i understand currently your question is like mine. If so then on WebSocketLink you can only pass tokens on connect. if your token expires and you get a new one, you need to reconnect with the new tokens. here is an example. And if that wasn't your question, then maybe it will help someone else.. :)

dreamer01 commented 4 years ago

@dreamer01 If i understand currently your question is like mine. If so then on WebSocketLink you can only pass tokens on connect. if your token expires and you get a new one, you need to reconnect with the new tokens. here is an example. And if that wasn't your question, then maybe it will help someone else.. :)

Hey, @lroy83 this what exactly I meant, thank you for sharing the code.

garrettmk commented 4 years ago

@lroy83 Oh man, I spent a long time searching for an example like what you posted. Thank you for sharing!

ARMATAV commented 4 years ago

@dreamer01 If i understand currently your question is like mine. If so then on WebSocketLink you can only pass tokens on connect. if your token expires and you get a new one, you need to reconnect with the new tokens. here is an example. And if that wasn't your question, then maybe it will help someone else.. :)

Finally an actual code example for how to refresh tokens with websocket properly! Pasting it here just because the past 2 examples I've been linked 404'd.

import { ApolloClient } from 'apollo-client'
import { split, from } from 'apollo-link'
import { createUploadLink } from 'apollo-upload-client'
import { InMemoryCache } from 'apollo-cache-inmemory'
import { SubscriptionClient } from 'subscriptions-transport-ws'
import MessageTypes from 'subscriptions-transport-ws/dist/message-types'
import { WebSocketLink } from 'apollo-link-ws'
import { getMainDefinition } from 'apollo-utilities'
import { createPersistedQueryLink } from 'apollo-link-persisted-queries'
import { setContext } from 'apollo-link-context'
import { withClientState } from 'apollo-link-state'

// Create the apollo client
export function createApolloClient ({
  // Client ID if using multiple Clients
  clientId = 'defaultClient',
  // URL to the HTTP API
  httpEndpoint,
  // Url to the Websocket API
  wsEndpoint = null,
  // Token used in localstorage
  tokenName = 'apollo-token',
  // Enable this if you use Query persisting with Apollo Engine
  persisting = false,
  // Is currently Server-Side Rendering or not
  ssr = false,
  // Only use Websocket for all requests (including queries and mutations)
  websocketsOnly = false,
  // Custom starting link.
  // If you want to replace the default HttpLink, set `defaultHttpLink` to false
  link = null,
  // If true, add the default HttpLink.
  // Disable it if you want to replace it with a terminating link using `link` option.
  defaultHttpLink = true,
  // Options for the default HttpLink
  httpLinkOptions = {},
  // Custom Apollo cache implementation (default is apollo-cache-inmemory)
  cache = null,
  // Options for the default cache
  inMemoryCacheOptions = {},
  // Additional Apollo client options
  apollo = {},
  // apollo-link-state options
  clientState = null,
  // Function returning Authorization header token
  getAuth = defaultGetAuth,
  // Local Schema
  typeDefs = undefined,
  // Local Resolvers
  resolvers = undefined,
  // Hook called when you should write local state in the cache
  onCacheInit = undefined,
}) {
  let wsClient, authLink, stateLink
  const disableHttp = websocketsOnly && !ssr && wsEndpoint

  // Apollo cache
  if (!cache) {
    cache = new InMemoryCache(inMemoryCacheOptions)
  }

  if (!disableHttp) {
    const httpLink = createUploadLink({
      uri: httpEndpoint,
      ...httpLinkOptions,
    })

    if (!link) {
      link = httpLink
    } else if (defaultHttpLink) {
      link = from([link, httpLink])
    }

    // HTTP Auth header injection
    authLink = setContext((_, { headers }) => {
      const authorization = getAuth(tokenName)
      const authorizationHeader = authorization ? { authorization } : {}
      return {
        headers: {
          ...headers,
          ...authorizationHeader,
        },
      }
    })

    // Concat all the http link parts
    link = authLink.concat(link)
  }

  // On the server, we don't want WebSockets and Upload links
  if (!ssr) {
    // If on the client, recover the injected state
    if (typeof window !== 'undefined') {
      // eslint-disable-next-line no-underscore-dangle
      const state = window.__APOLLO_STATE__
      if (state && state[clientId]) {
        // Restore state
        cache.restore(state[clientId])
      }
    }

    if (!disableHttp) {
      let persistingOpts = {}
      if (typeof persisting === 'object' && persisting != null) {
        persistingOpts = persisting
        persisting = true
      }
      if (persisting === true) {
        link = createPersistedQueryLink(persistingOpts).concat(link)
      }
    }

    // Web socket
    if (wsEndpoint) {
      wsClient = new SubscriptionClient(wsEndpoint, {
        reconnect: true,
        connectionParams: () => {
          const authorization = getAuth(tokenName)
          return authorization ? { authorization, headers: { authorization } } : {}
        },
      })

      // Create the subscription websocket link
      const wsLink = new WebSocketLink(wsClient)

      if (disableHttp) {
        link = wsLink
      } else {
        link = split(
          // split based on operation type
          ({ query }) => {
            const { kind, operation } = getMainDefinition(query)
            return kind === 'OperationDefinition' &&
              operation === 'subscription'
          },
          wsLink,
          link
        )
      }
    }
  }

  if (clientState) {
    console.warn(`clientState is deprecated, see https://vue-cli-plugin-apollo.netlify.com/guide/client-state.html`)
    stateLink = withClientState({
      cache,
      ...clientState,
    })
    link = from([stateLink, link])
  }

  const apolloClient = new ApolloClient({
    link,
    cache,
    // Additional options
    ...(ssr ? {
      // Set this on the server to optimize queries when SSR
      ssrMode: true,
    } : {
      // This will temporary disable query force-fetching
      ssrForceFetchDelay: 100,
      // Apollo devtools
      connectToDevTools: process.env.NODE_ENV !== 'production',
    }),
    typeDefs,
    resolvers,
    ...apollo,
  })

  // Re-write the client state defaults on cache reset
  if (stateLink) {
    apolloClient.onResetStore(stateLink.writeDefaults)
  }

  if (onCacheInit) {
    onCacheInit(cache)
    apolloClient.onResetStore(() => onCacheInit(cache))
  }

  return {
    apolloClient,
    wsClient,
    stateLink,
  }
}

export function restartWebsockets (wsClient) {
  // Copy current operations
  const operations = Object.assign({}, wsClient.operations)

  // Close connection
  wsClient.close(true)

  // Open a new one
  wsClient.connect()

  // Push all current operations to the new connection
  Object.keys(operations).forEach(id => {
    wsClient.sendMessage(
      id,
      MessageTypes.GQL_START,
      operations[id].options
    )
  })
}

function defaultGetAuth (tokenName) {
  if (typeof window !== 'undefined') {
    // get the authentication token from local storage if it exists
    const token = window.localStorage.getItem(tokenName)
    // return the headers to the context so httpLink can read them
    return token ? `Bearer ${token}` : ''
  }
}

It took literally forever to find your comment with this example.

Venryx commented 3 years ago

The code example by @pyankoff did not work for me.

Instead, I had to supply the auth token in connectionParams.authorization rather than connectionParams.headers.Authorization:

const wsLink = new WebSocketLink({
  uri: WS_URL,
  options: {
    lazy: true,
    reconnect: true,
    connectionParams: async () => {
      const token = await getToken();
      return {
        // this works for me
        authorization: token ? `Bearer ${token}` : "",

        // this did not work for me
        //headers: {Authorization: token ? `Bearer ${token}` : ""},
      };
    },
  },
});
Hemistone commented 3 years ago

@ARMATAV I've tried to implement code as mentioned, but it gives me type error for some of the wsClient functions. For example, when I try to call wsClient.sendMessage({}), it gives me Property 'sendMessage' is private and only accessible within class 'SubscriptionClient'. Is there any issue with typescript support?

ARMATAV commented 3 years ago

@ARMATAV I've tried to implement code as mentioned, but it gives me type error for some of the wsClient functions. For example, when I try to call wsClient.sendMessage({}), it gives me Property 'sendMessage' is private and only accessible within class 'SubscriptionClient'. Is there any issue with typescript support?

If I recall, yes - // @ts-ignore that thing and it will work I believe

victor-enogwe commented 2 years ago

For me I needed to pass the authentication information on every request in a stateless manner. WebSockets won't let you pass HttpHeaders as stated, and I did not want a stateful authentication via cookies. To accomplish this, we'll make use of the WebSocket protocols argument shown below to send the authentication information.

var aWebSocket = new WebSocket(url [, protocols]); https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket

According to the MDN docs, "The WebSocket.protocol read-only property returns the name of the sub-protocol the server selected;".

Your server implementation will likely have a check for this to ensure protocol compatibility. Good news is we can pass many protocols.

From the apollographql/subscription-transport-ws source code, the WebSocket client is instantiated within the connect private method

private connect() {
    this.client = new this.wsImpl(this.url, this.wsProtocols, ...this.wsOptionArguments);
    ...
}

https://github.com/apollographql/subscriptions-transport-ws/blob/a8bbabfe375090b44d4a8bd028356ec53d48a23a/src/client.ts#L557

We can pass the protools synchronously by instantiating the SubscriptionClient class like this:

const wsClient = new SubscriptionClient(<WS_LINK>, { reconnect: true, lazy: true }, <WebSocket implementation | undefined>, [, <protocols>]);

However I wanted to be able to asynchronously pass in the authentication information once it changes. The class property wsProtocol in the apollographql/subscription-transport-ws stores the protocol information, however it is private.

private wsProtocols: string | string[];

https://github.com/apollographql/subscriptions-transport-ws/blob/a8bbabfe375090b44d4a8bd028356ec53d48a23a/src/client.ts#L93

To asynchronously send the WebSocket Authentication information, we need to extend the SubsciptionClient as below:

import { ExecutionResult } from 'graphql';
import { BehaviorSubject, skip } from 'rxjs';
import { ClientOptions, Observable, OperationOptions, SubscriptionClient } from 'subscriptions-transport-ws';

export class CustomSubscriptionClient extends SubscriptionClient {
  protocols = new BehaviorSubject<string[]>(['graphql-ws']); // where `graphql-ws` is the default protocol

  constructor(url: string, options?: ClientOptions, webSocketImpl?: WebSocket, webSocketProtocols?: string | string[]) {
    super(url, options, webSocketImpl, webSocketProtocols);

    this.protocols.pipe(skip(1)).subscribe((wsProtocols) => {
      Object.assign(this, { wsProtocols }); // this hack overrides the private variable `wsProtocols` to set our authentication information
      this.close(false);
    });
  }

  public request(request: OperationOptions): Observable<ExecutionResult> {
    return super.request(request);
  }
}

We can then create a WebSocket client and link as follows:

...
const wsClient = new CustomSubscriptionClient(<WS_LINK>, { reconnect: true, lazy: true });
const wsLink = new WebSocketLink(wsClient.use([{ applyMiddleware: wsAuth }]));

Somewhere else in your code, e.g after authentication you can update the authentication information as follows:

wsClient.protocols.next(['graphql-ws',  <AUTH_INFO_STRING>])

On logout user you can do

wsClient.protocols.next(['graphql-ws'])

Note: This fix works by restarting the WebSocket client each time you set a new authentication information, it relies on the close method on the subscription client which restarts the WebSocket client if its isForced argument is false.

The server will then have access to the authentication information in its subprotocols headers on each request. I personally use the python channels subscription implementation on Django so my server authentication middleware looks like this:

class WSAuthMiddleware(BaseMiddleware):
    async def populate_scope(self, scope):
        token = scope.get("subprotocols")[1]
        user = None

        if token is None:
            raise ValueError("WSAuthMiddleware cannot find authorization in scope. ")

        # # Add it to the scope if it's not there already
        if "user" not in scope:
            scope["user"] = AnonymousUser()

        if token is not None:
            user = await sync_to_async(get_user_by_token, thread_sensitive=True)(
                token, scope
            )
            scope["user"] = user
    ...

Hope this helps someone.

Rhomennik commented 2 years ago

this work for me =>

connectionParams: async () => {
      const token = await getToken();
      return {
        // this works for me
        authorization: token ? `Bearer ${token}` : "",

        // this did not work for me
        //headers: {Authorization: token ? `Bearer ${token}` : ""},
      };
    },