Skip to content

Commit 5d2b7a4

Browse files
[IMP] peek into the JWT to get the channel uuid
Before this commit, the payload of the first websocket message (auth) would expect the channel uuid along the jwt, to know where to look to get the key used to sign it. It was slightly redundant as the channel uuid is part of the jwt payload. With the new JWT implementation, we now have the freedom to read the JWT before verifying it. It also reduces the business code complexity as we no longer need to check the corner case of passing a keyed channel uuid in the jwt while skipping the channelUUID of the websocket payload (see removed code in `connect()` of `ws.js`. This is safe to do as the payload is verified with the key of the channel, which means that the signature has to match the channel uuid and tampering with it would invalidate the content.
1 parent 3184fb1 commit 5d2b7a4

File tree

5 files changed

+94
-53
lines changed

5 files changed

+94
-53
lines changed

src/services/auth.js

+56-26
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ import { AuthenticationError } from "#src/utils/errors.js";
3434
* @property {string} [jti] - JWT ID
3535
*/
3636

37+
/**
38+
* @typedef {Object} JWTData
39+
* @property {JWTHeader} header - The JWT header
40+
* @property {JWTClaims} claims - The JWT claims
41+
* @property {Buffer} signature - The JWT signature
42+
* @property {string} signedData - The signed data (header + claims)
43+
*/
44+
3745
let jwtKey;
3846
const logger = new Logger("AUTH");
3947
const ALGORITHM = {
@@ -84,7 +92,7 @@ function base64Decode(str) {
8492
* Signs and creates a JsonWebToken
8593
*
8694
* @param {JWTClaims} claims - The claims to include in the token
87-
* @param {WithImplicitCoercion<string>} [key] - Optional key, defaults to the configured jwtKey
95+
* @param {WithImplicitCoercion<string> | Buffer} [key] - Optional key, defaults to the configured jwtKey
8896
* @param {Object} [options]
8997
* @param {string} [options.algorithm] - The algorithm to use, defaults to HS256
9098
* @returns {string} - The signed JsonWebToken
@@ -144,31 +152,53 @@ function safeEqual(a, b) {
144152
* @throws {AuthenticationError}
145153
*/
146154
export function verify(jsonWebToken, key = jwtKey) {
147-
const keyBuffer = Buffer.isBuffer(key) ? key : Buffer.from(key, "base64");
148-
let parsedJWT;
149-
try {
150-
parsedJWT = parseJwt(jsonWebToken);
151-
} catch {
152-
throw new AuthenticationError("Invalid JWT format");
153-
}
154-
const { header, claims, signature, signedData } = parsedJWT;
155-
const expectedSignature = ALGORITHM_FUNCTIONS[header.alg]?.(signedData, keyBuffer);
156-
if (!expectedSignature) {
157-
throw new AuthenticationError(`Unsupported algorithm: ${header.alg}`);
158-
}
159-
if (!safeEqual(signature, expectedSignature)) {
160-
throw new AuthenticationError("Invalid signature");
161-
}
162-
// `exp`, `iat` and `nbf` are in seconds (`NumericDate` per RFC7519)
163-
const now = Math.floor(Date.now() / 1000);
164-
if (claims.exp && claims.exp < now) {
165-
throw new AuthenticationError("Token expired");
166-
}
167-
if (claims.nbf && claims.nbf > now) {
168-
throw new AuthenticationError("Token not valid yet");
155+
const jwt = new JsonWebToken(jsonWebToken);
156+
return jwt.verify(key);
157+
}
158+
159+
export class JsonWebToken {
160+
/**
161+
* @type {JWTData}
162+
*/
163+
unsafe;
164+
/**
165+
* @param {string} jsonWebToken
166+
*/
167+
constructor(jsonWebToken) {
168+
let payload;
169+
try {
170+
payload = parseJwt(jsonWebToken);
171+
} catch {
172+
throw new AuthenticationError("Malformed JWT");
173+
}
174+
this.unsafe = payload;
169175
}
170-
if (claims.iat && claims.iat > now + 60) {
171-
throw new AuthenticationError("Token issued in the future");
176+
177+
/**
178+
* @param {WithImplicitCoercion<string>} [key] buffer/b64 str
179+
* @return {JWTClaims}
180+
*/
181+
verify(key = jwtKey) {
182+
const { header, claims, signature, signedData } = this.unsafe;
183+
const keyBuffer = Buffer.isBuffer(key) ? key : Buffer.from(key, "base64");
184+
const expectedSignature = ALGORITHM_FUNCTIONS[header.alg]?.(signedData, keyBuffer);
185+
if (!expectedSignature) {
186+
throw new AuthenticationError(`Unsupported algorithm: ${header.alg}`);
187+
}
188+
if (!safeEqual(signature, expectedSignature)) {
189+
throw new AuthenticationError("Invalid signature");
190+
}
191+
// `exp`, `iat` and `nbf` are in seconds (`NumericDate` per RFC7519)
192+
const now = Math.floor(Date.now() / 1000);
193+
if (claims.exp && claims.exp < now) {
194+
throw new AuthenticationError("Token expired");
195+
}
196+
if (claims.nbf && claims.nbf > now) {
197+
throw new AuthenticationError("Token not valid yet");
198+
}
199+
if (claims.iat && claims.iat > now + 60) {
200+
throw new AuthenticationError("Token issued in the future");
201+
}
202+
return claims;
172203
}
173-
return claims;
174204
}

src/services/ws.js

+7-16
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ import { Logger, extractRequestInfo } from "#src/utils/utils.js";
77
import { AuthenticationError, OvercrowdedError } from "#src/utils/errors.js";
88
import { SESSION_CLOSE_CODE } from "#src/models/session.js";
99
import { Channel } from "#src/models/channel.js";
10-
import { verify } from "#src/services/auth.js";
10+
import { JsonWebToken } from "#src/services/auth.js";
1111

1212
/**
1313
* @typedef Credentials
14-
* @property {string} channelUUID
14+
* @property {string} channelUUID deprecated, this is obtained from the jwt
1515
* @property {string} jwt
1616
*/
1717

@@ -53,7 +53,6 @@ export async function start(options) {
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);
5555
const session = connect(webSocket, {
56-
channelUUID: credentials?.channelUUID,
5756
jwt: credentials.jwt || credentials,
5857
});
5958
session.remote = remoteAddress;
@@ -102,22 +101,14 @@ export function close() {
102101
* @param {import("ws").WebSocket} webSocket
103102
* @param {Credentials}
104103
*/
105-
function connect(webSocket, { channelUUID, jwt }) {
106-
let channel = Channel.records.get(channelUUID);
107-
const authResult = verify(jwt, channel?.key);
108-
const { sfu_channel_uuid, session_id, ice_servers } = authResult;
109-
if (!channelUUID && sfu_channel_uuid) {
110-
// Cases where the channelUUID is not provided in the credentials for backwards compatibility with version 1.1 and earlier.
111-
channel = Channel.records.get(sfu_channel_uuid);
112-
if (channel.key) {
113-
throw new AuthenticationError(
114-
"A channel with a key can only be accessed by providing a channelUUID in the credentials"
115-
);
116-
}
117-
}
104+
function connect(webSocket, { jwt }) {
105+
const token = new JsonWebToken(jwt);
106+
const channel = Channel.records.get(token.unsafe.claims.sfu_channel_uuid);
118107
if (!channel) {
119108
throw new AuthenticationError(`Channel does not exist`);
120109
}
110+
const authResult = token.verify(channel.key);
111+
const { session_id, ice_servers } = authResult;
121112
if (!session_id) {
122113
throw new AuthenticationError("Malformed JWT payload");
123114
}

tests/network.test.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ describe("Full network", () => {
264264
expect(closeEvent.code).toBe(SESSION_CLOSE_CODE.P_TIMEOUT);
265265
});
266266
test("A client can broadcast arbitrary messages to other clients on a channel that does not have webRTC", async () => {
267-
const channelUUID = await network.getChannelUUID(false);
267+
const channelUUID = await network.getChannelUUID({ useWebRtc: false });
268268
const user1 = await network.connect(channelUUID, 1);
269269
const user2 = await network.connect(channelUUID, 2);
270270
const sender = await network.connect(channelUUID, 3);

tests/security.test.js

+13
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,17 @@ describe("Security", () => {
3939
const [event] = await once(websocket, "close");
4040
expect(event).toBe(WS_CLOSE_CODE.TIMEOUT);
4141
});
42+
test("cannot use the default jwt key to access a keyed channel", async () => {
43+
const channelUUID = await network.getChannelUUID({ key: "channel-specific-key" });
44+
const channel = Channel.records.get(channelUUID);
45+
await expect(network.connect(channelUUID, 3)).rejects.toThrow();
46+
expect(channel.sessions.size).toBe(0);
47+
});
48+
test("can join a keyed channel with the appropriate key", async () => {
49+
const key = "channel-specific-key";
50+
const channelUUID = await network.getChannelUUID({ key });
51+
const channel = Channel.records.get(channelUUID);
52+
await network.connect(channelUUID, 4, { key });
53+
expect(channel.sessions.size).toBe(1);
54+
});
4255
});

tests/utils/network.js

+17-10
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ import { Channel } from "#src/models/channel.js";
1212
const HMAC_B64_KEY = "u6bsUQEWrHdKIuYplirRnbBmLbrKV5PxKG7DtA71mng=";
1313
const HMAC_KEY = Buffer.from(HMAC_B64_KEY, "base64");
1414

15-
export function makeJwt(data) {
16-
return auth.sign(data, HMAC_KEY, { algorithm: "HS256" });
15+
export function makeJwt(data, { key = HMAC_KEY } = {}) {
16+
return auth.sign(data, key, { algorithm: "HS256" });
1717
}
1818

1919
/**
@@ -44,13 +44,15 @@ export class LocalNetwork {
4444
}
4545

4646
/**
47-
* @param {boolean} [useWebRtc]
47+
* @param {Object} [param0]
48+
* @param {boolean} [useWebRtc=true]
49+
* @param {string} [key] the channel-specific key
4850
* @returns {Promise<string>}
4951
*/
50-
async getChannelUUID(useWebRtc = true) {
52+
async getChannelUUID({ useWebRtc = true, key = HMAC_B64_KEY } = {}) {
5153
const jwt = this.makeJwt({
5254
iss: `http://${this.hostname}:${this.port}/`,
53-
key: HMAC_B64_KEY,
55+
key,
5456
});
5557
const response = await fetch(
5658
`http://${this.hostname}:${this.port}/v${http.API_VERSION}/channel?webRTC=${useWebRtc}`,
@@ -70,10 +72,12 @@ export class LocalNetwork {
7072
*
7173
* @param {string} channelUUID
7274
* @param {number} sessionId
75+
* @param {Object} [options]
76+
* @param {string} [options.key] the key to use to authenticate the session (this should be the key of the channel)
7377
* @returns { Promise<{ session: import("#src/models/session.js").Session, sfuClient: import("#src/client.js").SfuClient }>}
7478
* @throws {Error} if the client is closed before being authenticated
7579
*/
76-
async connect(channelUUID, sessionId) {
80+
async connect(channelUUID, sessionId, { key = HMAC_KEY } = {}) {
7781
const sfuClient = new SfuClient();
7882
this._sfuClients.push(sfuClient);
7983
sfuClient._createDevice = () => {
@@ -100,10 +104,13 @@ export class LocalNetwork {
100104
});
101105
sfuClient.connect(
102106
`ws://${this.hostname}:${this.port}`,
103-
this.makeJwt({
104-
sfu_channel_uuid: channelUUID,
105-
session_id: sessionId,
106-
}),
107+
this.makeJwt(
108+
{
109+
sfu_channel_uuid: channelUUID,
110+
session_id: sessionId,
111+
},
112+
{ key }
113+
),
107114
{ channelUUID }
108115
);
109116
const channel = Channel.records.get(channelUUID);

0 commit comments

Comments
 (0)