-
Notifications
You must be signed in to change notification settings - Fork 1
Add secure-ws library #22
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
base: main
Are you sure you want to change the base?
Changes from all commits
7213cd9
fe359a5
3d31022
64a4671
62657b4
6603205
42c7e43
1493375
1c36935
95ea7e8
36477e8
6ff441e
421f059
0958ebb
4a5939f
024f82a
f0f16c7
ee8cf47
f30b8c3
7bf514d
c32a879
644d138
568f2a9
37d83d9
5856992
a5b60c5
d26c68f
c8d6dbb
ec03b63
e84a201
5f60b33
b7e47d4
cf0c8fa
f36634f
c306e15
b083585
48ee8a7
ecb0ec7
9016a12
348cf65
05eeb34
3537477
6d02b62
e13aebc
398e7d6
2ce2dd2
5d003c0
fcb9da3
f03b152
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| name: Release and Publish secure-ws | ||
|
|
||
| on: | ||
| push: | ||
| paths: | ||
| - "secure-ws/**" | ||
|
|
||
| env: | ||
| BRANCH_TAG: "${{ github.ref_name == 'main' && 'latest' || github.ref_name }}" | ||
| BRANCH: ${{ github.ref_name }} | ||
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
|
|
||
| jobs: | ||
| publish_and_release: | ||
| name: Publish secure-ws package | ||
| environment: "${{ github.ref_name == 'main' && 'Prod' || 'Dev' }}" | ||
| runs-on: ubuntu-latest | ||
| defaults: | ||
| run: | ||
| working-directory: "secure-ws" | ||
| steps: | ||
| - name: Generate token | ||
| if: ${{ github.ref_name == 'main'}} | ||
| id: generate_token | ||
| uses: tibdex/github-app-token@v1 | ||
| with: | ||
| app_id: ${{ vars.FUNDABOT_APP_ID }} | ||
| private_key: ${{ secrets.FUNDABOT_PRIVATE_KEY }} | ||
|
|
||
| - uses: actions/setup-node@v3 | ||
| with: | ||
| node-version: 18 | ||
|
|
||
| - uses: actions/checkout@v3 | ||
| with: | ||
| token: ${{ github.ref_name == 'main' && steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }} | ||
|
|
||
| - name: Install dependencies | ||
| run: npm ci | ||
|
|
||
| - name: Build | ||
| run: npm run build | ||
|
|
||
| - name: Version bump | ||
| id: version | ||
| uses: phips28/gh-action-bump-version@v9.1.0 | ||
| with: | ||
| major-wording: ${{ env.BRANCH == 'main' && '[bump major]' || '[bump major --force]' }} | ||
| minor-wording: ${{ env.BRANCH == 'main' && '[bump minor]' || '[bump minor --force]' }} | ||
| patch-wording: ${{ null }} | ||
| rc-wording: ${{ null }} | ||
| default: "${{ env.BRANCH == 'main' && 'patch' || 'prerelease' }}" | ||
| PACKAGEJSON_DIR: "secure-ws" | ||
| preid: "${{ env.BRANCH }}" | ||
| skip-tag: "true" | ||
| skip-push: "true" | ||
| skip-commit: "true" | ||
| bump-policy: "ignore" | ||
|
|
||
| - name: Commit changes | ||
| env: | ||
| VERSION: ${{ steps.version.outputs.newTag }} | ||
| run: | | ||
| git config user.email "fundabot@fundwave.com" | ||
| git config user.name "fundabot" | ||
| git commit -a -m "CI: bumps secure-ws to $VERSION" -m "[skip ci]" | ||
|
|
||
| - name: Publish package to npm | ||
| run: | | ||
| echo //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN} > ~/.npmrc | ||
| echo registry=https://registry.npmjs.org/ >> ~/.npmrc | ||
| npm publish --tag $BRANCH_TAG --access public | ||
| env: | ||
| NODE_AUTH_TOKEN: ${{secrets.NPMJS_TOKEN}} | ||
|
|
||
| - name: Push changes | ||
| uses: ad-m/github-push-action@master | ||
| with: | ||
| github_token: ${{ env.GITHUB_TOKEN }} | ||
| branch: ${{ github.ref }} | ||
|
|
||
| - name: Release | ||
| if: ${{ github.ref_name == 'main'}} | ||
| env: | ||
| VERSION: ${{ steps.version.outputs.newTag }} | ||
| run: | | ||
| if [ "${BRANCH}" != "main" ]; then PRERELEASE="-p"; fi | ||
| echo "Releasing version ${VERSION} on branch ${BRANCH}" | ||
| gh release create ${VERSION} --target ${BRANCH} --generate-notes ${PRERELEASE} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| **/dist/** | ||
| **/node_modules/** |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,19 +1,23 @@ | ||
| threshold: medium | ||
| fileignoreconfig: | ||
| - filename: aws-credentials-utils/store-credentials.sh | ||
| checksum: 784aec6e80314be796af73887ba630fbaf0e116cc4d11bd5cba787a20f9c4bbb | ||
| - filename: jwks-slim/README.md | ||
| checksum: a1954df51e49fc6a09d7fe04bac198fe60a4fb39b2569180475a749a72df472d | ||
| - filename: jwks-slim/package-lock.json | ||
| checksum: d014d548f9f2997cbe1d7c4f7ec312f8c634e88788bc453ccc606ea70e081a77 | ||
| - filename: jwks-slim/tests/cache.test.js | ||
| checksum: 716f25000789d7ca18adfe07f968bae6eb2fbfa1c3d05e9e92897b9597ac5f17 | ||
| - filename: jwks-slim/tests/mock.js | ||
| checksum: 0d2fd2ec4847acda384c398e04e8aab9a615a8599b0a06d685e7fb100e71537c | ||
| - filename: jwks-slim/tests/keys.js | ||
| checksum: 207c0d07dea4c069883822b2b268d3b34c8b18bdfd645b8aa3b2be20f280c270 | ||
| - filename: jwks-slim/tests/index.test.js | ||
| checksum: 992a5c1f6c254fd344ae52955aa0c1c22a59e0472fe836b0450ff499255ef6f8 | ||
| - filename: aws-ssm/package-lock.json | ||
| checksum: 4cf91061e42ed9b1aafb17bdf68c9b4b24a7f86b191133ab4095fa635f8f01a0 | ||
| version: "" | ||
| - filename: aws-credentials-utils/store-credentials.sh | ||
| checksum: 784aec6e80314be796af73887ba630fbaf0e116cc4d11bd5cba787a20f9c4bbb | ||
| - filename: aws-ssm/package-lock.json | ||
| checksum: 4cf91061e42ed9b1aafb17bdf68c9b4b24a7f86b191133ab4095fa635f8f01a0 | ||
| - filename: jwks-slim/README.md | ||
| checksum: a1954df51e49fc6a09d7fe04bac198fe60a4fb39b2569180475a749a72df472d | ||
| - filename: jwks-slim/package-lock.json | ||
| checksum: d014d548f9f2997cbe1d7c4f7ec312f8c634e88788bc453ccc606ea70e081a77 | ||
| - filename: jwks-slim/tests/cache.test.js | ||
| checksum: 716f25000789d7ca18adfe07f968bae6eb2fbfa1c3d05e9e92897b9597ac5f17 | ||
| - filename: jwks-slim/tests/index.test.js | ||
| checksum: 992a5c1f6c254fd344ae52955aa0c1c22a59e0472fe836b0450ff499255ef6f8 | ||
| - filename: jwks-slim/tests/keys.js | ||
| checksum: 207c0d07dea4c069883822b2b268d3b34c8b18bdfd645b8aa3b2be20f280c270 | ||
| - filename: jwks-slim/tests/mock.js | ||
| checksum: 0d2fd2ec4847acda384c398e04e8aab9a615a8599b0a06d685e7fb100e71537c | ||
| - filename: secure-ws/mocks/express-response.ts | ||
| checksum: 7774e72616894119c419f7ad86a3da579036b0391fd208766bcdcb96401b8d2c | ||
| - filename: websocket-provider/package-lock.json | ||
| checksum: 605a2d92b91f08ee577d3b1e7ed38d27eef43573a044608c22466f05ab6eaff7 | ||
| threshold: medium | ||
| version: "1.0" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| # Exclude TypeScript source files and configs | ||
| src/ | ||
| *.ts | ||
| *.tsx | ||
| tsconfig.json | ||
| # Exclude node_modules | ||
| node_modules/ | ||
| # Exclude test files and folders | ||
| test/ | ||
| *.spec.* | ||
| *.test.* | ||
| # Exclude build scripts and configs | ||
| *.log | ||
| *.env | ||
| # Exclude editor and OS files | ||
| .DS_Store | ||
| .vscode/ | ||
| .idea/ | ||
| # Exclude other common files | ||
| coverage/ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| # secure-ws | ||
|
|
||
| secure-ws is a TypeScript library for secure WebSocket servers that lets you run Express-style middleware during the upgrade handshake, ensuring unauthenticated connections are never left open. | ||
|
|
||
| ## Why use secure-ws | ||
|
|
||
| - Run middleware during the WebSocket upgrade handshake | ||
| - Reuse Express Middlewares for authentication, validation and more | ||
| - Abstract away connection, upgrade, and messaging with Express-style routes, middleware, and controllers. | ||
|
|
||
| ## Installation | ||
|
|
||
| ``` | ||
| npm install --save secure-ws | ||
| ``` | ||
|
|
||
| ## Usage | ||
|
|
||
| ```typescript | ||
| import express from "express"; | ||
| import { WebSocketProvider } from 'secure-ws'; | ||
|
|
||
| const wsApp = new WebSocketProvider(); | ||
| const app = express(); | ||
|
|
||
| // app.get... | ||
| // app.post... | ||
|
|
||
| wsApp.addRoute( | ||
| "/extract/metrics", | ||
| { | ||
| onConnect: [authMiddleware1, authMiddleware2], | ||
| onMessage: [controller] | ||
| } | ||
| ); | ||
|
|
||
| const httpServer = app.listen(PORT, () => { | ||
| console.info(`Service running at port: ${PORT}`); | ||
| }); | ||
|
|
||
| httpServer.on('upgrade', wsApp.handleUpgrade); | ||
| ``` | ||
|
|
||
| ## License | ||
|
|
||
| MIT |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| import { WebSocketServer } from 'ws'; | ||
| import { IncomingMessage } from 'http'; | ||
| import { decode } from "@msgpack/msgpack"; | ||
| import { randomUUID } from "crypto"; | ||
|
|
||
| import { WSProtocolCodec } from './ws-protocol-codec'; | ||
| import { runExpressMiddleware } from '../utils/middleware-adapter'; | ||
| import { injectHttpRequest } from '../utils/inject-http-request'; | ||
|
|
||
| import { WSController } from '../types/ws-controller'; | ||
| import { AddRouteParams } from '../types/add-route-params'; | ||
| import { ClientSocket } from '../types/client-socket'; | ||
| import { Duplex } from 'stream'; | ||
|
|
||
| export class WebSocketProvider { | ||
| public server: WebSocketServer; | ||
| public clientSockets: Map<string, ClientSocket>; | ||
| private routes: Record<string, AddRouteParams>; | ||
|
|
||
| constructor() { | ||
| this.server = new WebSocketServer({ noServer: true }); | ||
| this.server.on('connection', this.handleConnection); | ||
| this.routes = {}; | ||
| this.clientSockets = new Map(); | ||
| } | ||
|
|
||
| public addRoute = ( | ||
| path: string, | ||
| { | ||
| onConnect, | ||
| onMessage, | ||
| } : AddRouteParams | ||
| ) => { | ||
| this.routes[path] = { | ||
| onConnect, | ||
| onMessage | ||
| } | ||
| } | ||
| public handleUpgrade = (request: IncomingMessage, socket: Duplex, head: Buffer) => { | ||
| const { pathname } = new URL(request.url!, 'wss://base.url'); | ||
|
|
||
| if (this.routes[pathname]) { | ||
| this.server.handleUpgrade(request, socket, head, (ws: ClientSocket) => { | ||
|
|
||
| ws.id = `${Date.now()}-${randomUUID()}`; | ||
| this.clientSockets.set(ws.id, ws); | ||
| ws.on('close', () => { | ||
| this.clientSockets.delete(ws.id); | ||
| }); | ||
|
|
||
| this.server.emit('connection', ws, request); | ||
| }); | ||
| } else { | ||
| console.log('No route for path:', pathname); | ||
| socket.end(); | ||
| } | ||
| }; | ||
|
|
||
| private handleConnection = async (ws: ClientSocket, request: IncomingMessage) => { | ||
| const { pathname } = new URL(request.url!, 'wss://base.url'); | ||
|
|
||
| let injectedRequest = {} as IncomingMessage; | ||
|
|
||
| if (typeof ws.protocol === 'string' && ws.protocol.trim() !== '') { | ||
| const httpRequest = WSProtocolCodec.decode(ws.protocol); | ||
| injectedRequest = injectHttpRequest(request, httpRequest); | ||
| } | ||
|
|
||
| if(!this.routes[pathname]) { | ||
| ws.close(1000, 'Unknown path'); | ||
| return; | ||
| } | ||
|
|
||
| const { onConnect, onMessage } = this.routes[pathname]; | ||
|
|
||
| for (const middleware of onConnect) { | ||
| const res = await runExpressMiddleware(middleware, injectedRequest, ws); | ||
| if (!res.success) { | ||
| console.error("Middleware rejected connection:", res.error); | ||
| ws.send(JSON.stringify({ error: res.error })); | ||
| ws.close(1000, res.error); | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| ws.on('message', this.handleMessage(ws, onMessage)); | ||
| ws.send(JSON.stringify({ type: "connection:ack" })); | ||
| }; | ||
|
|
||
| private handleMessage = (socket: ClientSocket, onMessage: WSController[]) => { | ||
| return async (message) => { | ||
| const request = decode(message) as IncomingMessage; | ||
|
|
||
| for (const controller of onMessage) { | ||
| await controller(request, socket); | ||
| } | ||
| }; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| export class WSProtocolCodec { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rather Base64UrlSafeCodec if it isn't specific to WS
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is specific to the websocket as the name of the websocket protocol ( |
||
| static encode(data: unknown): string { | ||
| const json = JSON.stringify(data); | ||
| return Buffer.from(json).toString('base64') | ||
| .replace(/\+/g, "-") | ||
| .replace(/\//g, "_") | ||
| .replace(/=+$/, ""); | ||
| } | ||
|
|
||
| static decode<T = unknown>(encoded: string): T { | ||
| let str = encoded.replace(/-/g, "+").replace(/_/g, "/"); | ||
| while (str.length % 4) str += "="; | ||
| const json = Buffer.from(str, 'base64').toString(); | ||
| return JSON.parse(json) as T; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export * from './core/web-socket-provider'; | ||
| export * from './core/ws-protocol-codec'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| import { WebSocket } from "ws"; | ||
| import { MockResponse as MockResponseType } from "../types/mock-response"; | ||
|
|
||
| export class MockResponse implements MockResponseType { | ||
| private socket: WebSocket; | ||
| public statusCode: number = 200; | ||
|
|
||
| constructor(socket: WebSocket) { | ||
| this.socket = socket; | ||
| } | ||
|
|
||
| status(code: number) { | ||
| this.statusCode = code; | ||
| return this; | ||
| } | ||
|
|
||
| send(data) { | ||
| const responseType = this.statusCode >= 400 ? "error" : "success"; | ||
| this.socket.send(JSON.stringify({ | ||
| type: responseType, | ||
| status: this.statusCode, | ||
| data: data | ||
| })); | ||
| this.socket.close(); | ||
| return this; | ||
| } | ||
|
|
||
| sendStatus(code: number) { | ||
| this.statusCode = code; | ||
| const responseType = code >= 400 ? "error" : "success"; | ||
| this.socket.send(JSON.stringify({ | ||
| status: code, | ||
| statusText: this.getStatusText(), | ||
| type: responseType | ||
| })); | ||
| this.socket.close(); | ||
| return this; | ||
| } | ||
|
|
||
| private getStatusText(): string { | ||
| const statusTexts: { [key: number]: string } = { | ||
| 200: 'OK', | ||
| 401: 'Unauthorized', | ||
| 403: 'Forbidden', | ||
| 404: 'Not Found', | ||
| 500: 'Internal Server Error' | ||
| }; | ||
| return statusTexts[this.statusCode] || 'Unknown'; | ||
| } | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what if we set noServer to false? Then we are able to set our own upgrade logic in the specified http server?
https://www.reddit.com/r/node/comments/sfgmum/can_someone_kindly_explain_what_noserver_mode/
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If noServer is set to false (which it is by default), it spins up an http server on the specified port. In this case we won't have any control over the upgrade logic.
In our case, we already have an http server provided by express and we want to just use the socket connection after upgrade happens. Hence
noServer.The alternative is to pass the server to the constructor.
But even in that case, this.server.handleUpgrade will be called automatically on all upgrades. We don't want this as we only want to upgrade if the enpoint matches a registered route [Ref lines 38-41 core/web-socket-provider.ts]
From docs:
Refs:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So the idea is to run the WS server in parallel with regular REST API? I'd imagine having a separate server for WS only might help plan scaling up and rate limiting better.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that depends on the number of ws endpoints one wants across services. If we want to have one endpoint for most of the services that would mean a common ws server would need access to all resources for those microservices. This ws server would become a monolith soon. For just a few endpoints, having a common ws makes sense for scalability but, in my opinion, the drawbacks would overcome the benefits as the number of endpoints increase.
The other alternative would be to have ws microservices in parallel with the existing microservices. But that would nearly double the number of microservices we have right now, leading to additional overhead of managing these services.
Another approach that was discussed previously was to have a common ws-service that only manages a single socket connection from the client and relays the request to the respective services. However it would still need ws endpoints on the services that it connects to. We can add rate limiting rules to this service and then later plug this between the client and server with the current architecture.