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

feat: Authentication by Websocket #3955

Open
1 task done
gustawdaniel opened this issue Mar 9, 2023 · 17 comments
Open
1 task done

feat: Authentication by Websocket #3955

gustawdaniel opened this issue Mar 9, 2023 · 17 comments
Labels
✅ accepted-PRs-welcome Feature proposal is accepted and ready to work on @trpc/client

Comments

@gustawdaniel
Copy link

gustawdaniel commented Mar 9, 2023

Describe the feature you'd like to request

We know that websocket do not use http headers, but during handshake http request can be used.

Page 14 RFC 6455 https://www.rfc-editor.org/rfc/rfc6455#page-14

Current implementation sending these headers

{
    'sec-websocket-version': '13',
    'sec-websocket-key': 'vCv/hUdrmek4c0XSKnrCgA==',
    connection: 'Upgrade',
    upgrade: 'websocket',
    'sec-websocket-extensions': 'permessage-deflate; client_max_window_bits',
    host: 'localhost:3001'
  },

But I would like to add my custom headers here, eg Authorization.

Connected reddit topic of other user:
https://www.reddit.com/r/node/comments/117fgb5/trpc_correct_way_to_authorize_websocket/

Describe the solution you'd like to see

This problem was solved in apollo client by

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

in issue apollographql/apollo-client#3967

or even better

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

Probably when this function will return new headers (eg new bearer token will be added) we probably need restart all operations.

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

so it seems to be complicated.

Describe alternate solutions

Alternatively we can think about onOpen function in createWSClient or if it is called after handshake, then maybe add function beforeOpen and give end user control over connecting and reconnecting when headers will be changed.

Additional information

I can try to contribute with this but need to know if this feature will be accepted and how to design it.

👨‍👧‍👦 Contributing

  • 🙋‍♂️ Yes, I'd be down to file a PR implementing this feature!

I wrote article about approaches to this problem, so you can read details and discuss here which one should be preferred as official, and maybe instead of this feature we will add docs covering this problem.

https://preciselab.io/trpc/

Summary:

  • we can pass token but have to wrap Websocket by proxy and be able to read tokens when client is created
  • so it would be better to add lazy flag like in apollo client
  • alternative global state on backend that allow to pass info about clients authentication
  • third option is passing token to input of subscriptions

TRP-60

Funding

  • You can sponsor this specific effort via a Polar.sh pledge below
  • We receive the pledge once the issue is completed & verified
Fund with Polar
@KATT KATT added @trpc/client ✅ accepted-PRs-welcome Feature proposal is accepted and ready to work on labels Mar 9, 2023
@KATT
Copy link
Member

KATT commented Mar 9, 2023

In the WS example it works as we use cookies for auth, but agree it would be nice to do this

@gustawdaniel
Copy link
Author

@KATT i am ready to help, but we need to establish which solution should be selected as default.

All of them has tradeoffs.

Do you prefer to discuss it on meeting:
https://calendly.com/gustaw-daniel

or enumerate goals here.


My proposal:

  • add lazy flag and headers like in apollo client
  • expose method to reconnect when headers will change, ( scenario: you do not have cookies, connect, but login, change cookies and need reconnect to load cookies to websocket context )
  • add chapter in docs about authenticating in websocket with all approaches

What will be covered in docs

  • auth by cookies
  • auth by Authorization header
  • global users state on server and sec-websocket-id approach
  • passing auth payload on any subscription

Please let me know if you agree or want to modify this plan?

@gunhaxxor
Copy link
Contributor

This got me curious. Is it actually possible to add headers when establishing a websocket connection? All my research into this had me believe that it isn't possible to create a websocket new Websocket(url); and set specific headers.
https://stackoverflow.com/questions/4361173/http-headers-in-websockets-client-api

Or would this involve implementing the upgrade/handshake manually?

Regardless, I recently added the possibility to provide the connectionUrl as a getter function for the createWsClient. In my case this works fine, by setting the auth token as a query parameter in the connectionUrl. But I guess this exposes the token in a higher degree compared to if it was in a header.🤔

@KATT KATT added the linear label May 25, 2023
@KATT KATT changed the title feat: Authentication by Websocket [TRP-60] feat: Authentication by Websocket May 25, 2023
@KATT KATT added Feature and removed linear labels May 25, 2023
@KATT KATT changed the title [TRP-60] feat: Authentication by Websocket feat: Authentication by Websocket May 25, 2023
@KATT
Copy link
Member

KATT commented Jun 26, 2023

Feel free to submit PRs to this -- I'm happy with any solution that copies existing solutions out there like how Apollo, urql, or Relay does this

@m1daz
Copy link

m1daz commented Jun 26, 2023

Feel free to submit PRs to this -- I'm happy with any solution that copies existing solutions out there like how Apollo, urql, or Relay does this

4 hours later, and a lot of debugging, I finally managed to fix the issue, which was for some reason due to me not defining onStarted as an event to the websocket. For me, the issue was resolved as I use cookie auth anyways. It's just that I had a bug with the way I initialized it that forced me to authenticate a different way, I will delete my original comment now because it is misleading.

@KATT KATT removed the Feature label Aug 24, 2023
@sofimiazzi
Copy link

sofimiazzi commented Aug 29, 2023

This feature would prove exceptionally valuable and impressive, particularly if it includes built-in support for managing refresh tokens. Its utility would be evident in single-page applications (SPAs) or across various mobile and desktop applications, thereby broadening the reach and application of tRPC.

@mbrimmer83
Copy link

I don't have any bandwidth to work on this, but pledged $100 to help support this work. Would love this feature.

@ps73
Copy link

ps73 commented Sep 11, 2023

I am doing it by having a trpc mutation handler called authenticate and setting a parameter ws inside context when the response is an instance of WebSocket:

export const createContext = <TRequest, TResponse>(
  opts: NodeHTTPCreateContextFnOptions<TRequest, TResponse>,
) => {
  return {
    ...ctx,
    req: opts.req,
    ws: opts.res instanceof WebSocket ? opts.res : undefined,
  };
};
const router = router({
  authenticate: publicProcedure.input(authenticateSchema).mutation(async ({ input, ctx }) => {
    const { session } = await getSessionByAccessToken(input.accessToken);
    if (!session) throw ctx.errors.unauthorized();
    if (ctx.ws) {
      // Set the access token on the websocket connection
      ctx.ws.accessToken = input.accessToken;
    }
    return {
      accessToken: input.accessToken,
      expiresAt: jwt.exp * 1000,
      user: session.user,
    };
  }),
});

Now when you call the authenticate route with the websocket link it saves the access token if valid inside the unique websocket connection.

After that inside an authenticate middleware you can get the access token on header or websocket connection like this:

const accessToken = ctx.ws?.accessToken || ctx.req.headers.authorization;
if (!accessToken) throw ctx.errors.unauthorized();
// your other logic

I am also using refresh tokens the same way. Just implement a refresh token mutation handler with input of access and refresh token, set newly created access token on websocket connection ctx.ws.accessToken = newAccessToken.

For me this is the most intuitive method to authenticate and reauthenticate users inside a (long running) websocket connection without having to handle reconnections after invalidating old access tokens or when tokens expires during active websocket connection.

@mkuchak
Copy link

mkuchak commented Nov 28, 2023

I've been researching, and you can prompt the client to redo the WebSocket connection if the access token changes.

/**
 * Helper function for using authentication on WebSockets.
 * @param getUrl A function that returns the URL of the API with the access token.
 */
const protectedWsClient = (getUrl: () => string) => {
  let client: TRPCWebSocketClient | undefined;
  let prevUrl: string;
  return {
    close() {
      client?.close();
      client = undefined;
    },
    request(op: Operation, callbacks: TCallbacks) {
      const url = getUrl();
      if (!client || prevUrl !== url) {
        client?.close();
        prevUrl = url;
        client = createWSClient({ url });
      }
      return client.request(op, callbacks);
    },
    getConnection() {
      if (!client) {
        throw new Error("No WebSocket connection.");
      }
      return client.getConnection();
    },
  };
};

Then create the wsLink like this:

// ...
wsLink({
  client: protectedWsClient(() => {
    const baseUrl = url.replace(/^https?/, "ws");
    const tokenQuery = `session=${getAccessToken()}`;
    return `${baseUrl}?${tokenQuery}`;
  }),
})
// ...

And on back-end get the access token from URL:

const getQueryParam = (url: string | undefined, paramName: string): string | undefined => {
  if (!url) return undefined;
  const params = new URLSearchParams(url.split("?")[1]);
  const param = params.get(paramName);
  return param ?? undefined;
};

const accessToken = ctx.req?.headers?.authorization?.split(" ")[1] ?? getQueryParam(ctx.req?.url, "session");

To make all of this work automatically, you can use the @pyncz/trpc-refresh-token-link NPM package to handle token refresh.

It works like a charm, but you expose the access token directly in the WebSocket connection endpoint — which, in theory, wouldn't be a problem with token rotation in the refresh token and a short lifespan for the access token.

Additionally, in general, using URL query parameters on WebSocket are considered safe practice because:

  • Headers aren't supported by WebSockets;
  • Headers are advised against during the HTTP -> WebSocket upgrade because CORS is not enforced;
  • SSL encrypts query paramaters;
  • Browsers don't cache WebSocket connections the same way they do with URLs.

Credits to jitnouz.

@troch
Copy link

troch commented Dec 2, 2023

Hello, I've been following this issue as when it was initially created, we had been started to use tRPC and try to get rid of nestJS / graphQL.

For websockets we ended up rolling our own, for multiple reasons. But I can provide some feedback on what solution we ended up implemented for authentication (and authorization), in case it helps:

  • We have a dedicated server for websocket updates, which is basically a mapper of Redis messages to websocket messages. We use #uNetworking/uWebSockets.js and performance has been amazing so far.
  • We don't use authentication headers, but handle authentication with websocket messages: when a connection is open, the server challenges auth with a message. The client has a number of seconds to respond or the server terminates the connection. That way we have a single mechanism for authenticating, it allows the server to challenge auth periodically without terminating the connection. Because we use Redis Pub/Sub, messages can be received at most once, and closing the connection could mean lost messages (we probably will want in the future to have at least once behaviours, or at the very least implement a cache of last messages, but for now it is how it works).
  • Another reason for using messages: as well as protecting connections with authentication, we protect subscribtions with authorization. When subscribing to a specific thing, a ressource JWT is provided (not the authentication one). And similarly, we can challenge periodically.

@ps73
Copy link

ps73 commented Dec 4, 2023

Hi @troch, that sounds good.
My provided solution here is mostly a similar solution. We are also using messages for authentication, since headers needs a reconnect every time the token changes and query parameter is not secure at all.

@sofimiazzi
Copy link

sofimiazzi commented Dec 5, 2023

Hi @troch, that sounds good. My provided solution here is mostly a similar solution. We are also using messages for authentication, since headers needs a reconnect every time the token changes and query parameter is not secure at all.

How do you pass the websocket connection in a mutation? I'm using Fastify adapter and the websocket connection is only shared with subscription, so it's impossible to set the access token in the context of the websocket during mutation.

In that case ws: opts.res instanceof WebSocket never will going to be a websocket instance during a mutation, so I don't have access to the ctx.ws to set the token.

@ps73
Copy link

ps73 commented Dec 6, 2023

I think it is working for us because we are using the WebSocket for mutations and query as well.

For subscription only WebSocket my implementation cannot work.

It comes from NodeHTTPCreateContextFnOptions<IncomingMessage, ws> as a parameter inside the createContext method from applyWSSHandler from '@trpc/server/adapters/ws';

const handler = applyWSSHandler({
    wss,
    router: options.trpcRouter,
    createContext: (opts) => { // <- here opts.res is instance of WebSocket
      return createContext?.(opts);
    },
  });

@sofimiazzi
Copy link

@ps73 Of course! You are not using splitLink, just a direct WebSocket connection to the API. In my use case, I think the API would be overwhelmed with so many unnecessary WebSocket connections connected to the server at the same time.

Even if I had a second client just for WebSocket, if an HTTP connection updated the access token, the WebSocket connection would not have the new value.

I will explore alternative solutions, but I appreciate your response!

@corbinu
Copy link

corbinu commented Mar 12, 2024

Would a possible solution be to support client-side middleware when using wsLink?

@iamclaytonray
Copy link

Following this. I've had to write some hacks in my codebases for a solution to get auth to work, albeit rather janky. In the meantime, could some docs be written on how to handle authorization headers with websockets?

@opiredev
Copy link

opiredev commented May 1, 2024

@nabby27 created a $100.00 reward using Opire

How to earn this reward?

Since this project does not have Opire bot installed yet 😞 you need to go to Opire and claim the rewards through your programmer's dashboard once you have your PR ready 💪

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
✅ accepted-PRs-welcome Feature proposal is accepted and ready to work on @trpc/client
Projects
None yet
Development

No branches or pull requests