This commit is contained in:
Morten Olsen
2025-10-14 23:08:12 +02:00
commit 521ffd395f
14 changed files with 5524 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
import z from 'zod';
const statementSchema = z.object({
effect: z.enum(['allow', 'disallow']),
resources: z.array(z.string()),
actions: z.array(z.string()),
});
type Statement = z.infer<typeof statementSchema>;
export type { Statement };
export { statementSchema };

View File

@@ -0,0 +1,29 @@
import type { Statement } from './access.schemas.ts';
import { validate } from './access.utils.ts';
type SessionOptions = {
statements: Statement[];
};
type ValidateOptions = {
action: string;
resource: string;
};
class Session {
#options: SessionOptions;
constructor(options: SessionOptions) {
this.#options = options;
}
public validate = (options: ValidateOptions) => {
const { statements } = this.#options;
return validate({
...options,
statements,
});
};
}
export { Session };

View File

@@ -0,0 +1,36 @@
import { z } from 'zod';
import jwt from 'jsonwebtoken';
import { statementSchema } from './access.schemas.ts';
type AccessTokensOptions = {
secret: string | Buffer;
};
const tokenBodySchema = z.object({
statements: z.array(statementSchema),
});
type TokenBody = z.infer<typeof tokenBodySchema>;
class AccessTokens {
#options: AccessTokensOptions;
constructor(options: AccessTokensOptions) {
this.#options = options;
}
public generate = (options: TokenBody) => {
const { secret } = this.#options;
const token = jwt.sign(options, secret);
return token;
};
public validate = (token: string) => {
const { secret } = this.#options;
const data = jwt.verify(token, secret);
const parsed = tokenBodySchema.parse(data);
return parsed;
};
}
export { AccessTokens };

2
src/access/access.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './access.session.ts';
export * from './access.token.ts';

View File

@@ -0,0 +1,24 @@
import micromatch from 'micromatch';
import type { Statement } from './access.schemas.ts';
type ValidateOptions = {
action: string;
resource: string;
statements: Statement[];
};
const validate = (options: ValidateOptions) => {
const { statements, resource, action } = options;
const matches = statements.filter(
(statement) => micromatch.isMatch(resource, statement.resources) && micromatch.isMatch(action, statement.actions),
);
if (matches.length === 0) {
return false;
}
if (matches.find((statement) => statement.effect === 'disallow')) {
return false;
}
return true;
};
export { validate };

0
src/exports.ts Normal file
View File

117
src/server/server.ts Normal file
View File

@@ -0,0 +1,117 @@
import http from 'node:http';
import tcp from 'node:net';
import { WebSocketServer, createWebSocketStream } from 'ws';
import {
createBroker,
type AuthenticateHandler,
type AuthorizeForwardHandler,
type AuthorizePublishHandler,
type AuthorizeSubscribeHandler,
type PublishedHandler,
} from 'aedes';
import { AedesMemoryPersistence } from 'aedes-persistence';
import { Session } from '../access/access.session.ts';
import type { AccessTokens } from '#root/access/access.token.ts';
type Aedes = ReturnType<typeof createBroker>;
declare module 'aedes' {
export interface Client {
session: Session;
}
}
type MqttServerOptions = {
accessTokens: AccessTokens;
};
class MqttServer {
#options: MqttServerOptions;
#server: Aedes;
#http?: http.Server;
#tcp?: tcp.Server;
constructor(options: MqttServerOptions) {
this.#options = options;
this.#server = createBroker({
persistence: new AedesMemoryPersistence(),
authenticate: this.#authenticate,
authorizePublish: this.#authorizePublish,
authorizeSubscribe: this.#authorizeSubscribe,
authorizeForward: this.#authorizeForward,
published: this.#published,
});
}
#authenticate: AuthenticateHandler = (client, _username, password, callback) => {
if (!password) {
throw new Error('unauthorized');
}
const { accessTokens } = this.#options;
const auth = accessTokens.validate(password.toString('utf8'));
client.session = new Session({
statements: auth.statements,
});
callback(null, true);
};
#authorizePublish: AuthorizePublishHandler = (client, packet, callback) => {
const authorized = client?.session.validate({
action: 'mqtt:publish',
resource: `mqtt:${packet.topic}`,
});
if (!authorized) {
return callback(new Error('unauthorized'));
}
callback();
};
#authorizeSubscribe: AuthorizeSubscribeHandler = (client, subscription, callback) => {
const authorized = client?.session.validate({
action: 'mqtt:subscribe',
resource: `mqtt:${subscription.topic}`,
});
if (!authorized) {
return callback(new Error('unauthorized'), null);
}
callback(null, subscription);
};
#authorizeForward: AuthorizeForwardHandler = (client, packet) => {
const authorized = client.session.validate({
action: 'mqtt:forward',
resource: `mqtt:${packet.topic}`,
});
if (!authorized) {
return;
}
return packet;
};
#published: PublishedHandler = (_packet, _client, callback) => {
callback();
};
public getHttpServer = () => {
if (!this.#http) {
this.#http = http.createServer();
const wss = new WebSocketServer({
server: this.#http,
});
wss.on('connection', (websocket, req) => {
const stream = createWebSocketStream(websocket);
this.#server.handle(stream, req);
});
}
return this.#http;
};
public getTcpServer = () => {
if (!this.#tcp) {
this.#tcp = tcp.createServer(this.#server.handle);
}
return this.#tcp;
};
}
export { MqttServer };