Git Product home page Git Product logo

Comments (5)

WinstonFassett avatar WinstonFassett commented on August 27, 2024 19

I've been running with the above approach for several months and it works, including reconnecting but it's got some issues.

  • There is no way to send a bearer token in the header from the browser, and I'd rather not use cookies, nor have access tokens in URLs, even if it is secured over wss.
  • There is no way to send an access denied message back to the client in server.on('upgrade').
    • Despite this being the recommended auth approach here: https://github.com/websockets/ws/#client-authentication, it does not appear capable of being graceful. I tried it and the browser does not not get the unauthorized message.
    • I've tried a few approaches like socket.close(code, reason) but nothing seems to reach the browser.
    • I have concluded the only way to send a message back to the browser is to complete the handshake and open the connection.

So I set about rewriting last night and while researching, stumbled across this issue. So here's my new approach.

Authenticate websocket connections over websockets

Today I rewrote my authentication implementation to authenticate over websockets before handing off to YJS and I like it a lot better.

Basically, on server:

  • Don't authenticate in wss.handleUpgrade
  • Allow the websocket to connect and send the access token as text, then call internal authenticate handler
  • If auth is invalid, send an access-denied message and close the websocket
  • if valid, call the real _setupWSConnection

same on client:

  • when connecting a new websocket, hijack the websocket handlers from yjs until authenticated
  • send the access token over the websocket
  • await response
  • if authenticated, then setup the websocket. If access-denied, disconnect provider (permanently) to prevent undesired econnects.

Unfortunately it's a bit invasive, but at this point I have my own local embedded fork of y-websocket server code rewritten as a class, so I tried it and it worked.

Sample Code

Allow the connection, wire up to the message handler and set conn.authenticated = false. But do NOT call setupWSConnection yet.

  _onConnection (conn, req, { docName = req.url.slice(1), gc = true } = {}) {
    conn.authenticated = false;
    conn.on('message', message => {
      this._onMessage(conn, conn.doc, message);
    }); 
  }  

In message handler, if the message is a string, authenticate with it.
Once authenticated, forward messages to original YJS message handler.

  async _onMessage (conn, doc, message) {
    if (typeof message === 'string') {
      const authenticated = await this._authenticate(conn, message);
      conn.authenticated = authenticated;
      if (authenticated) {        
        conn.send('authenticated');
        this._setupWSConnection(conn, conn.docName, conn.gc);
      } else {
        conn.send('access-denied');
        conn.close();
      }
    } else if (conn.authenticated) {
      this._onCollabMessage(conn, doc, new Uint8Array(message));
    } else {    
      // being precautious but not actually using this yet.  may not need it
      conn.preauthenticatedMessages.push(message);
    }
  }

On the client I currently monkey-patch provider.ws to plug in the auth handlers but I might move this into the y-websocket client:

  const provider = new WebsocketProvider(VUE_APP_YS_ENDPOINT, docName , ydoc);
  
  const onConnecting = () => {
    const provider_onmessage = provider.ws.onmessage;
    provider.ws.onmessage = event => {
      const { data } = event
      if (typeof data === 'string') {
        switch (data) {
          case 'authenticated':
            provider.ws.onmessage = provider_onmessage
            provider_onopen();
            break;
          case 'authentication-failed':
            provider.disconnect();
            break;
          default:
            break;
        }
      }
    }    
    const provider_onopen = provider.ws.onopen
    provider.ws.onopen = event => {      
      provider.ws.send(accessToken);
    }
  }
  onConnecting();

Because the y-websocket client calls setupWS on every reconnection attempt, I have to monkey-patch it every time to get it to go through authentication again.
So I have y-websocket emit a 'status' event with a status of 'connecting', and I use that to re-apply the authentication handlers as new websockets are created.

  provider.on('status', ({status}) => {
    if (status === 'connecting') {
      onConnecting()
    }
  })

This was the shortest distance I could find to a working implementation.

The server is difficult to extend from the outside, with the binary protocol and lack of class methods to override or options to override behavior. I'm not sure how best to extend it or whether it is intended to be forked rather than extended, but here are some ideas:

  • emit events (this is just all around useful and I'm also doing it with persistence to trigger indexing)
    • on the server I emit authenticated, opened, closed, connected, disconnected and use this for tracking live docs and connections.
    • on client I just added another status event of connecting
  • to avoid having to shuffle event handlers around?
    • export a class where users can easily override specific bits
    • provide a bunch of named options that override the default internal functions
  • OR implement authentication in y-websocket
    • provide generic auth implementation as part of binary protocol and add handling to y-websocket, i.e.
      • servers create the y-websocket server with an authenticate: async function authenticate(accessToken, docName, conn) option which tells y-websocket to use the auth protocol up-front and call that authenticate handler when it receives an access token.
      • after creating the provider, clients call await doc.provider.authenticate(accessToken) in order to go from connecting to connected.
    • some benefits I see are
      • eliminating the confusion and questions around doing authentication with y-websocket
      • avoiding half-baked y-websocket implementations across the community using the ungraceful 'upgrade' approach with cookies or tokens in URLs
      • massively improved compatibility of secure y-websocket client apps and servers built by different developers
      • potentially also use for web-rtc / p2p auth?

from y-websocket.

ivan-nemtinov avatar ivan-nemtinov commented on August 27, 2024 13

To do auth and stop client reconnections we can use websocket's connection.close(CUSTOM_CODE). It's fast, because websockets are not used to send auth messages.

On the server side:

export const WEBSOCKET_AUTH_FAILED = 4000;

this.wss.on("connection", this.onConnection);
protected onConnection = async (connection: WebSocket, request: http.IncomingMessage) => {
console.log("socket on connection");

// authenticate can get JWT from URL or cookie
if (!this.authenticate(request)) {
	connection.close(WEBSOCKET_AUTH_FAILED);
}

...

}

On the client side (modify y-websocket.js), see // Do not reconnect if auth failed:

websocket.onclose = (e) => {
  provider.ws = null
  provider.wsconnecting = false
  if (provider.wsconnected) {
    provider.wsconnected = false
    provider.synced = false
    // update awareness (all users except local left)
    awarenessProtocol.removeAwarenessStates(provider.awareness, Array.from(provider.awareness.getStates().keys()).filter(client => client !== provider.doc.clientID), provider)
    provider.emit('status', [{
      status: 'disconnected'
    }])
  } else {
    provider.wsUnsuccessfulReconnects++
  }

  // Do not reconnect if auth failed
  if(e.code === WEBSOCKET_AUTH_FAILED) {
      console.log("Auth failed", e.code);
      return;
  }

  // Start with no reconnect timeout and increase timeout by
  // log10(wsUnsuccessfulReconnects).
  // The idea is to increase reconnect timeout slowly and have no reconnect
  // timeout at the beginning (log(1) = 0)
  setTimeout(setupWS, math.min(math.log10(provider.wsUnsuccessfulReconnects + 1) * reconnectTimeoutBase, maxReconnectTimeout), provider)
}

from y-websocket.

raineorshine avatar raineorshine commented on August 27, 2024 1

I took @WinstonFassett's code and made a fork that supports generic access token authentication. The server exports an authenticate method, and the client adds the auth field to the WebsocketProvider constructor. Pass your access token on auth and it will be forwarded to the authenticate method:

Server:

const { createServer } = require('y-websocket-auth/server')

const server = createServer({ 
  // accessToken is passed as { auth: ACCESS_TOKEN } 
  // in the WebsocketProvider constructor on the client-side
  authenticate: (accessToken: string) => {
    // do authentication
    return true
  }
})

server.listen(port, host, () => {
  console.log(`running at '${host}' on port ${port}`)
})

Client:

const wsProvider = new WebsocketProvider(
  'ws://localhost:1234', 
  'my-roomname', 
  ydoc, 
  { auth: ACCESS_TOKEN }
)

Hope this helps others. Happy to accept contributions if you'd like to extend the API.

from y-websocket.

janaka avatar janaka commented on August 27, 2024

Hey @WinstonFassett, thanks for your details post on authN. I you changes to y-protocol and y-websocket to add support didn't get merged into the main project right?

Is forking the best approach here? just want to make sure I'm not missing a trick.

from y-websocket.

jiangxiaoqiang avatar jiangxiaoqiang commented on August 27, 2024

I took @WinstonFassett's code and made a fork that supports generic access token authentication. The server exports an authenticate method, and the client adds the auth field to the WebsocketProvider constructor. Pass your access token on auth and it will be forwarded to the authenticate method:

Server:

const { createServer } = require('y-websocket-auth/server')

const server = createServer({ 
  // accessToken is passed as { auth: ACCESS_TOKEN } 
  // in the WebsocketProvider constructor on the client-side
  authenticate: (accessToken: string) => {
    // do authentication
    return true
  }
})

server.listen(port, host, () => {
  console.log(`running at '${host}' on port ${port}`)
})

Client:

const wsProvider = new WebsocketProvider(
  'ws://localhost:1234', 
  'my-roomname', 
  ydoc, 
  { auth: ACCESS_TOKEN }
)

Hope this helps others. Happy to accept contributions if you'd like to extend the API.

the demo did not mention the auth failed logic. the key is how to handle the failed of auth.

from y-websocket.

Related Issues (20)

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.