trpc
trpc copied to clipboard
feat: Authentication by Websocket
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 https://github.com/apollographql/apollo-client/issues/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
- [X] ๐โโ๏ธ 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
Funding
- You can sponsor this specific effort via a Polar.sh pledge below
- We receive the pledge once the issue is completed & verified
In the WS example it works as we use cookies for auth, but agree it would be nice to do this
@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?
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.๐ค
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
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.
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.
I don't have any bandwidth to work on this, but pledged $100 to help support this work. Would love this feature.
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.
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.
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.
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.
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.
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);
},
});
@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!
Would a possible solution be to support client-side middleware when using wsLink?
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?
How much money do I need to pledge to get this implemented? ๐ฅบ
I was hopeful https://github.com/trpc/trpc/pull/5713 would give us a solution to authenticated realtime communication, but when I tested it out, I realized EventSource doesn't even provide a sane way to authenticate out of the box without using cookies.
My goal is: Authenticate (populate my server's context) when the WebSocket connection is opened. I don't want to have to re-authenticate for every new subscription. I think Apollo's solution of connectionParams
is essentially what is needed here
Hey guys
I've made an example of how you can do WebSocket auth by leveraging that the url
-property of createWSClient()
can be a callback.
Is this what people are looking for? From what I have gathered, there's no other way to modify headers as part of a WebSocket initialization so it's either cookies or a property to the URL that are the only ways.
See #5838 - does this satisfy what people are looking for?
& if we did do connectionParams
as a first class citizen, do we expect those to be sent as a query string? AFAICT, that's the only solution to to "fake" non-cookie headers in WebSockets
Ideally, I'd like to send connectionParams
over the websocket connection when it is opened. I have a React Native app that uses long-lived auth tokens and I'd prefer to not put that token in the URL as a query string. @KATT
Thanks for working on this ๐๐ผ
Yeah, I see how it's preferable with a message, I did it this way initially to be able to use the same solution across SSE and WS
I've made it work with an initial message now instead, just awaiting some reviews
This feature has been added in the next
-branch of tRPC, meaning it is available in v11 of tRPC.
It's possible to start using it today, have a look at the migration guide for more info: https://trpc.io/docs/migrate-from-v10-to-v11
This issue has been locked because we are very unlikely to see comments on closed issues. If you are running into a similar issue, please create a new issue. Thank you.