Skip to content

Commit

Permalink
Rework middlewares and auth
Browse files Browse the repository at this point in the history
  • Loading branch information
oklemenz2 committed Jan 26, 2024
1 parent 8a20304 commit 960c141
Show file tree
Hide file tree
Showing 23 changed files with 342 additions and 154 deletions.
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

## Version 0.5.0 - 2024-02-xx

### Added

- Provide user context in examples and tests, to verify authorization flow

### Fixed

- Refactor middlewares and authorization check
- Change `cds.ws` to point to CDS websocket server (not the native implementation, use `cds.wss` or `cds.io` for that)

## Version 0.4.0 - 2024-01-26

### Added
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,8 @@ The CDS Websocket module supports the following use-cases:

### WebSocket Server

The websocket server is exposed on `cds` object implementation-independent at `cds.ws` and implementation-specific at
`cds.io` or `cds.wss`. Additional listeners can be registered bypassing CDS definitions and runtime.
The CDS websocket server is exposed on `cds` object implementation-independent at `cds.ws` and implementation-specific at
`cds.wss` for WebSocket Standard or `cds.io` for Socket.IO. Additional listeners can be registered bypassing CDS definitions and runtime.
WebSocket server options can be provided via `cds.websocket.options`.

Default protocol path is `/ws` and can be overwritten via `cds.env.protocols.websocket.path` resp. `cds.env.protocols.ws.path`;
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cap-js-community/websocket",
"version": "0.4.0",
"version": "0.5.0",
"description": "WebSocket adapter for CDS",
"homepage": "https://cap.cloud.sap/",
"engines": {
Expand Down
173 changes: 127 additions & 46 deletions src/socket/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,60 +68,141 @@ class SocketServer {
async broadcast(service, event, data, socket, remote) {}

/**
* Mock the HTTP response object and make available at request.res
* @param {Object} request HTTP request
* Handle HTTP request response
* @param {object} Server socket
* @param {Number} statusCode Response status code
* @param {string} body Response body
*/
static mockResponse(request) {
// Mock response (not available in websocket, CDS middlewares need it)
const res = request.res ?? {};
res.headers ??= {};
res.set ??= (name, value) => {
res.headers[name] = value;
if (name.toLowerCase() === "x-correlation-id") {
request.correlationId = value;
respond(socket, statusCode, body) {
if (statusCode >= 400) {
this.close(socket, 4000 + statusCode, body);
}
}

/**
* Close socket and disconnect client. If no socket is passed the server is closed
* @param {object} socket Socket to be disconnected
* @param {integer} code Reason code for close
* @param {string} reason Reason text for close
*/
close(socket, code, reason) {
}

/**
* Middlewares executed before
* @returns {[function]} Returns a list of middleware functions
*/
beforeMiddlewares() {
return [this.mockResponse.bind(this), this.applyAuthCookie.bind(this)];
}

/**
* Middlewares executed after
* @returns {[function]} Returns a list of middleware functions
*/
afterMiddlewares() {
return [];
}

/**
* Get all middlewares
* @returns {[function]} Returns a list of middleware functions
*/
middlewares() {
function wrapMiddleware(middleware) {
return (socket, next) => {
return middleware(socket.request, socket.request.res, next);
};
}

const middlewares = this.beforeMiddlewares() || [];
for (const middleware of cds.middlewares?.before ?? []) {
if (Array.isArray(middleware)) {
for (const entry of middleware) {
middlewares.push(wrapMiddleware(entry));
}
} else {
middlewares.push(wrapMiddleware(middleware));
}
return res;
};
res.setHeader ??= (name, value) => {
return res.set(name, value);
};
res.status ??= (statusCode) => {
res.statusCode = statusCode;
return res;
};
res.writeHead ??= (statusCode, statusMessage, headers) => {
res.status(statusCode);
res.headers = { ...res.headers, ...headers };
return res;
};
res.json ??= (json) => {
res.body = json;
return res;
};
res.send ??= (text) => {
res.body = text;
return res;
};
res.end ??= () => {
return res;
};
res.on ??= () => {
return res;
};
request.res = res;
}
return middlewares.concat(this.afterMiddlewares());
}

/**
* Mock the HTTP response object and make available at req.res
* @param {Object} socket Server socket
* @param {function} next Call next
*/
mockResponse(socket, next) {
const req = socket.request;
let error = null;
try {
// Mock response (not available in websocket, CDS middlewares need it)
const res = req.res ?? {};
res.headers ??= {};
res.set ??= (name, value) => {
res.headers[name] = value;
if (name.toLowerCase() === "x-correlation-id") {
req.correlationId = value;
}
return res;
};
res.setHeader ??= (name, value) => {
return res.set(name, value);
};
res.status ??= (statusCode) => {
res.statusCode = statusCode;
return res;
};
res.writeHead ??= (statusCode, statusMessage, headers) => {
res.status(statusCode);
res.headers = { ...res.headers, ...headers };
return res;
};
res.json ??= (json) => {
this.respond(socket, res.statusCode, JSON.stringify(json));
res.body = json;
return res;
};
res.send ??= (text) => {
this.respond(socket, res.statusCode, text);
res.body = text;
return res;
};
res.end ??= () => {
return res;
};
res.on ??= () => {
return res;
};
req.res = res;
} catch (err) {
error = err;
} finally {
next(error);
}
}

/**
* Apply the authorization cookie to authorization header for local authorization testing in mocked auth scenario
* @param {Object} request HTTP request
* @param {Object} socket Server socket
* @param {function} next Call next
*/
static applyAuthCookie(request) {
// Apply cookie to authorization header
if (["mocked"].includes(cds.env.requires?.auth?.kind) && !request.headers.authorization && request.headers.cookie) {
const cookies = cookie.parse(request.headers.cookie);
if (cookies["X-Authorization"] || cookies["Authorization"]) {
request.headers.authorization = cookies["X-Authorization"] || cookies["Authorization"];
applyAuthCookie(socket, next) {
const req = socket.request;
let error = null;
try {
// Apply cookie to authorization header
if (["mocked"].includes(cds.env.requires?.auth?.kind) && !req.headers.authorization && req.headers.cookie) {
const cookies = cookie.parse(req.headers.cookie);
if (cookies["X-Authorization"] || cookies["Authorization"]) {
req.headers.authorization = cookies["X-Authorization"] || cookies["Authorization"];
}
}
} catch (err) {
error = err;
} finally {
next(error);
}
}
}
Expand Down
54 changes: 23 additions & 31 deletions src/socket/socket.io.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,16 @@ class SocketIOServer extends SocketServer {
this.io.engine.on("connection_error", (err) => {
LOG?.error(err);
});
cds.ws = this.io;
cds.ws = this;
cds.io = this.io;
}

async setup() {
await this._applyAdapter();
await this.applyAdapter();
}

service(service, connected) {
const io = this._applyMiddleware(this.io.of(service));
const io = this.applyMiddleware(this.io.of(service));
io.on("connection", async (socket) => {
try {
DEBUG?.("Connected", socket.id);
Expand All @@ -39,7 +39,7 @@ class SocketIOServer extends SocketServer {
service,
socket,
setup: () => {
this._enforceAuth(socket);
this.enforceAuth(socket);
},
context: () => {
return {
Expand Down Expand Up @@ -82,7 +82,15 @@ class SocketIOServer extends SocketServer {
}
}

async _applyAdapter() {
close(socket) {
if (socket) {
socket.disconnect();
} else {
this.io.close();
}
}

async applyAdapter() {
try {
const adapterImpl = cds.env.websocket?.adapter?.impl;
if (adapterImpl) {
Expand All @@ -96,9 +104,11 @@ class SocketIOServer extends SocketServer {
switch (adapterImpl) {
case "@socket.io/redis-adapter":
client = await redis.createPrimaryClientAndConnect();
subClient = await redis.createSecondaryClientAndConnect();
if (client && subClient) {
this.adapter = adapterFactory.createAdapter(client, subClient, options);
if (client) {
subClient = await redis.createSecondaryClientAndConnect();
if (subClient) {
this.adapter = adapterFactory.createAdapter(client, subClient, options);
}
}
break;
case "@socket.io/redis-streams-adapter":
Expand All @@ -118,36 +128,18 @@ class SocketIOServer extends SocketServer {
}
}

_applyMiddleware(io) {
io.use((socket, next) => {
SocketServer.mockResponse(socket.request);
SocketServer.applyAuthCookie(socket.request);
next();
});
for (const middleware of cds.middlewares?.before ?? []) {
if (Array.isArray(middleware)) {
for (const entry of middleware) {
io.use(wrapMiddleware(entry));
}
} else {
io.use(wrapMiddleware(middleware));
}
applyMiddleware(io) {
for (const middleware of this.middlewares()) {
io.use(middleware);
}
return io;
}

_enforceAuth(io) {
if (io.request.isAuthenticated && !io.request.isAuthenticated()) {
io.disconnect();
enforceAuth(socket) {
if (socket.request.isAuthenticated && !socket.request.isAuthenticated()) {
throw new Error("403 - Forbidden");
}
}
}

function wrapMiddleware(middleware) {
return (socket, next) => {
return middleware(socket.request, socket.request.res, next);
};
}

module.exports = SocketIOServer;
Loading

0 comments on commit 960c141

Please sign in to comment.