Skip to content

Commit 6577df8

Browse files
[IMP] remove dependency on jwt library
Instead, it is now a local implementation of JWT, this makes the server less dependent on external libraries for its security and removes a potential source of dependency injection. This commit also removes unnecessary async/await (the old jsonwebtoken was also used synchronously).
1 parent 4fc865e commit 6577df8

File tree

7 files changed

+264
-155
lines changed

7 files changed

+264
-155
lines changed

package-lock.json

Lines changed: 0 additions & 138 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
"#tests/*": "./tests/*"
2424
},
2525
"dependencies": {
26-
"jsonwebtoken": "^9.0.2",
2726
"mediasoup": "~3.15.6",
2827
"ws": "^8.18.1"
2928
},

src/services/auth.js

Lines changed: 115 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
1-
import jwt from "jsonwebtoken";
1+
import crypto from "node:crypto";
22

33
import * as config from "#src/config.js";
44
import { Logger } from "#src/utils/utils.js";
55
import { AuthenticationError } from "#src/utils/errors.js";
66

77
let jwtKey;
88
const logger = new Logger("AUTH");
9+
const ALGORITHM = {
10+
HS256: "HS256",
11+
};
12+
const ALGORITHM_FUNCTIONS = {
13+
[ALGORITHM.HS256]: (data, key) => crypto.createHmac("sha256", key).update(data).digest(),
14+
};
915

1016
/**
1117
* @param {WithImplicitCoercion<string>} [key] buffer/b64 str
1218
*/
13-
export async function start(key) {
19+
export function start(key) {
1420
const keyB64str = key || config.AUTH_KEY;
1521
jwtKey = Buffer.from(keyB64str, "base64");
1622
logger.info(`auth key set`);
@@ -20,18 +26,119 @@ export function close() {
2026
jwtKey = undefined;
2127
}
2228

29+
/**
30+
* @param {Buffer|string} data - The data to encode
31+
* @returns {string} - base64 encoded string
32+
*/
33+
function base64Encode(data) {
34+
if (typeof data === "string") {
35+
data = Buffer.from(data);
36+
}
37+
return data.toString("base64");
38+
}
39+
40+
/**
41+
* @param {string} str base64 encoded string
42+
* @returns {Buffer}
43+
*/
44+
function base64Decode(str) {
45+
let output = str;
46+
const paddingLength = 4 - (output.length % 4);
47+
if (paddingLength < 4) {
48+
output += "=".repeat(paddingLength);
49+
}
50+
return Buffer.from(output, "base64");
51+
}
52+
53+
/**
54+
* Signs and creates a JWT token
55+
*
56+
* @param {Object} payload - The payload to include in the token
57+
* @param {WithImplicitCoercion<string>} [key] - Optional key, defaults to the configured jwtKey
58+
* @param {Object} [options]
59+
* @param {string} [options.algorithm] - The algorithm to use, defaults to HS256
60+
* @returns {string} - The signed JWT token
61+
* @throws {AuthenticationError}
62+
*/
63+
export function sign(payload, key = jwtKey, { algorithm = ALGORITHM.HS256 } = {}) {
64+
if (!key) {
65+
throw new AuthenticationError("JWT signing key is not set");
66+
}
67+
const keyBuffer = Buffer.isBuffer(key) ? key : Buffer.from(key, "base64");
68+
const headerB64 = base64Encode(JSON.stringify({ alg: algorithm, typ: "JWT" }));
69+
const payloadB64 = base64Encode(JSON.stringify(payload));
70+
const signedData = `${headerB64}.${payloadB64}`;
71+
const signature = ALGORITHM_FUNCTIONS[algorithm]?.(signedData, keyBuffer);
72+
if (!signature) {
73+
throw new AuthenticationError("Unsupported algorithm");
74+
}
75+
const signatureB64 = base64Encode(signature);
76+
return `${headerB64}.${payloadB64}.${signatureB64}`;
77+
}
78+
79+
/**
80+
* Parses a JWT token into its components
81+
*
82+
* @param {string} token
83+
* @returns {{header: object, payload: object, signature: Buffer, signedData: string}}
84+
*/
85+
function parseJwt(token) {
86+
const parts = token.split(".");
87+
if (parts.length !== 3) {
88+
throw new AuthenticationError("Invalid JWT format");
89+
}
90+
const [headerB64, payloadB64, signatureB64] = parts;
91+
const header = JSON.parse(base64Decode(headerB64).toString());
92+
const payload = JSON.parse(base64Decode(payloadB64).toString());
93+
const signature = base64Decode(signatureB64);
94+
const signedData = `${headerB64}.${payloadB64}`;
95+
96+
return { header, payload, signature, signedData };
97+
}
98+
99+
function safeEqual(a, b) {
100+
if (a.length !== b.length) {
101+
return false;
102+
}
103+
try {
104+
return crypto.timingSafeEqual(a, b);
105+
} catch {
106+
return false;
107+
}
108+
}
109+
23110
/**
24111
* @param {string} jsonWebToken
25112
* @param {WithImplicitCoercion<string>} [key] buffer/b64 str
26-
* @returns {Promise<any>} json serialized data
113+
* @returns {any} json serialized data
27114
* @throws {AuthenticationError}
28115
*/
29-
export async function verify(jsonWebToken, key = jwtKey) {
116+
export function verify(jsonWebToken, key = jwtKey) {
117+
const keyBuffer = Buffer.isBuffer(key) ? key : Buffer.from(key, "base64");
118+
let parsedJWT;
30119
try {
31-
return jwt.verify(jsonWebToken, key, {
32-
algorithms: ["HS256"],
33-
});
120+
parsedJWT = parseJwt(jsonWebToken);
34121
} catch {
35-
throw new AuthenticationError("JsonWebToken verification error");
122+
throw new AuthenticationError("Invalid JWT format");
123+
}
124+
const { header, payload, signature, signedData } = parsedJWT;
125+
const expectedSignature = ALGORITHM_FUNCTIONS[header.alg]?.(signedData, keyBuffer);
126+
if (!expectedSignature) {
127+
throw new AuthenticationError("Unsupported algorithm");
128+
}
129+
if (!safeEqual(signature, expectedSignature)) {
130+
throw new AuthenticationError("Invalid signature");
131+
}
132+
// `exp`, `iat` and `nbf` are in seconds (`NumericDate` per RFC7519)
133+
const now = Math.floor(Date.now() / 1000);
134+
if (payload.exp && payload.exp < now) {
135+
throw new AuthenticationError("Token expired");
136+
}
137+
if (payload.nbf && payload.nbf > now) {
138+
throw new AuthenticationError("Token not valid yet");
139+
}
140+
if (payload.iat && payload.iat > now + 60) {
141+
throw new AuthenticationError("Token issued in the future");
36142
}
143+
return payload;
37144
}

src/services/http.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export async function start({ httpInterface = config.HTTP_INTERFACE, port = conf
5353
try {
5454
const jsonWebToken = req.headers.authorization?.split(" ")[1];
5555
/** @type {{ iss: string, key: string || undefined }} */
56-
const claims = await auth.verify(jsonWebToken);
56+
const claims = auth.verify(jsonWebToken);
5757
if (!claims.iss) {
5858
logger.warn(`${remoteAddress}: missing issuer claim when creating channel`);
5959
res.statusCode = 403; // forbidden
@@ -82,7 +82,7 @@ export async function start({ httpInterface = config.HTTP_INTERFACE, port = conf
8282
try {
8383
const jsonWebToken = await parseBody(req);
8484
/** @type {{ sessionIdsByChannel: Object<string, number[]> }} */
85-
const claims = await auth.verify(jsonWebToken);
85+
const claims = auth.verify(jsonWebToken);
8686
for (const [channelUuid, sessionIds] of Object.entries(
8787
claims.sessionIdsByChannel
8888
)) {

src/services/ws.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,11 @@ export async function start(options) {
4848
logger.warn(`${remoteAddress} WS timed out, closing it`);
4949
unauthenticatedWebSockets.delete(currentPendingId);
5050
}, config.timeouts.authentication);
51-
webSocket.once("message", async (message) => {
51+
webSocket.once("message", (message) => {
5252
try {
5353
/** @type {Credentials | String} can be a string (the jwt) for backwards compatibility with version 1.1 and earlier */
5454
const credentials = JSON.parse(message);
55-
const session = await connect(webSocket, {
55+
const session = connect(webSocket, {
5656
channelUUID: credentials?.channelUUID,
5757
jwt: credentials.jwt || credentials,
5858
});
@@ -102,10 +102,10 @@ export function close() {
102102
* @param {import("ws").WebSocket} webSocket
103103
* @param {Credentials}
104104
*/
105-
async function connect(webSocket, { channelUUID, jwt }) {
105+
function connect(webSocket, { channelUUID, jwt }) {
106106
let channel = Channel.records.get(channelUUID);
107107
/** @type {{sfu_channel_uuid: string, session_id: number, ice_servers: Object[] }} */
108-
const authResult = await verify(jwt, channel?.key);
108+
const authResult = verify(jwt, channel?.key);
109109
const { sfu_channel_uuid, session_id, ice_servers } = authResult;
110110
if (!channelUUID && sfu_channel_uuid) {
111111
// Cases where the channelUUID is not provided in the credentials for backwards compatibility with version 1.1 and earlier.

0 commit comments

Comments
 (0)