import assert from 'assert'; import http from 'http'; import { mintAsapToken, mintToken } from './helpers/jwt.js'; import { prosodyShell } from './helpers/prosody_shell.js'; import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js'; /** * GETs a test-observer endpoint and parses the JSON response. * * @param {string} route e.g. 'session-info' * @param {string} jid full JID to look up * @returns {Promise} */ function getObserverJson(route, jid) { return new Promise((resolve, reject) => { const url = `http://localhost:5280/test-observer/${route}?jid=${encodeURIComponent(jid)}`; http.get(url, res => { let body = ''; res.on('data', chunk => { body += chunk; }); res.on('end', () => { if (res.statusCode !== 200) { reject(new Error(`${route} returned ${res.statusCode}: ${body}`)); return; } try { resolve(JSON.parse(body)); } catch (e) { reject(new Error(`${route} bad JSON: ${body}`)); } }); }).on('error', reject); }); } /** * Fetches the session fields snapshotted at resource-bind time. * * @param {string} jid * @returns {Promise} */ function getSessionInfo(jid) { return getObserverJson('session-info', jid); } /** * Fetches the CURRENT JWT-derived fields of a live session. Needed after a * XEP-0198 resume, which refreshes the claims without binding a new resource * (so the /session-info snapshot is not updated). * * @param {string} jid * @returns {Promise} */ function getSessionLive(jid) { return getObserverJson('session-live', jid); } /** Connects to the HS256 VirtualHost. */ function hs256Client(params) { return createXmppClient({ domain: 'hs256.localhost', params }); } describe('mod_auth_token (HS256 shared secret)', () => { const clients = []; afterEach(async () => { await Promise.all(clients.map(c => c.disconnect())); clients.length = 0; }); it('connects successfully with a valid token', async () => { const token = mintToken({ room: '*' }); const c = await hs256Client({ token }); clients.push(c); assert.ok(c.jid, 'client should have a JID after connecting'); }); it('rejects connection with wrong secret', async () => { const token = mintToken({}, { secret: 'wrongsecret' }); await assert.rejects( () => hs256Client({ token }), /not-allowed/ ); }); it('rejects connection with expired token', async () => { const token = mintToken({}, { expired: true }); await assert.rejects( () => hs256Client({ token }), /not-allowed/ ); }); it('rejects connection with not-yet-valid token (nbf in the future)', async () => { const token = mintToken({}, { notYetValid: true }); await assert.rejects( () => hs256Client({ token }), /not-allowed/ ); }); it('rejects connection with wrong issuer', async () => { const token = mintToken({ iss: 'other-app' }); await assert.rejects( () => hs256Client({ token }), /not-allowed/ ); }); it('sets session.jitsi_meet_context_features from token context', async () => { const token = mintToken({ room: '*', context: { features: { 'screen-sharing': true, 'recording': false } } }); const c = await hs256Client({ token }); clients.push(c); const info = await getSessionInfo(c.jid); assert.strictEqual(info.jitsi_meet_context_features['screen-sharing'], true); assert.strictEqual(info.jitsi_meet_context_features.recording, false); }); it('sets session.jitsi_meet_room from room claim', async () => { const token = mintToken({ room: 'testroom' }); const c = await hs256Client({ token }); clients.push(c); const info = await getSessionInfo(c.jid); assert.strictEqual(info.jitsi_meet_room, 'testroom'); }); }); /** * XEP-0198 stream resumption re-runs SASL on the new connection, so a client * may present a different JWT than the one it joined with. mod_auth_token * refreshes the hibernating session's claims from that token — that is how a * client rotates an expiring token across a reconnect — and the claims it * adopts have to stay scoped to the conference the session is an occupant of, * the same way the room claim is scoped at join time. * * The room claim of the refreshed token is therefore re-checked against the * rooms the session already occupies (mod_token_verification answers the * 'jitsi-verify-session-rooms' event), and the claims verified on join are kept * when it does not cover them. The file-sharing component is used here as the * observable effect of context.features on a live session. */ describe('mod_auth_token (claims on XEP-0198 resumption)', () => { const CONFERENCE = 'conference.localhost'; const BREAKOUT_MUC = 'breakout.conference.localhost'; const FILESHARING_COMPONENT = 'filesharing.localhost'; const JITMEET_NS = 'http://jitsi.org/jitmeet'; const clients = []; let roomCounter = 0; afterEach(async () => { await Promise.all(clients.map(c => c.disconnect())); clients.length = 0; }); /** Connects a client carrying ?room= and ?token= on the token VirtualHost. */ function tokenClient(roomName, token) { return createXmppClient({ params: { room: roomName, token } }); } /** Mints an RS256 login token scoped to `room`, optionally with features. */ function tokenFor(room, features) { return mintAsapToken({ room, context: { user: { id: 'user-a', name: 'user-a' }, ...features ? { features } : {} } }); } /** * Sends a file-sharing add with a caller-chosen stanza id, so the reply can * be matched by id. After a resume the server flushes the unacked stanzas * queued during hibernation, so an earlier error can be redelivered. */ function addFile(client, id, fileId) { return client.sendFileSharingRaw( FILESHARING_COMPONENT, { id }, { type: 'add' }, JSON.stringify({ fileId, name: 'shared.txt', size: 0, type: 'text/plain' }) ); } /** Waits for the reply to the stanza with the given id. */ function waitForReply(client, id, timeout = 3000) { return client.waitForMessage(s => s.attrs.id === id, timeout); } /** Asserts a reply is an auth/forbidden error. */ function assertForbidden(stanza, message) { assert.equal(stanza.attrs.type, 'error', message); assert.ok(stanza.getChild('error')?.getChild('forbidden'), `${message} (expected , got ${stanza.toString()})`); } /** Resolves with the filesharing broadcast payload received by `client`. */ async function waitForBroadcast(client, timeout = 3000) { const stanza = await client.waitForMessage( s => s.attrs.from === FILESHARING_COMPONENT && s.getChild('json-message', JITMEET_NS) !== undefined, timeout); return JSON.parse(stanza.getChild('json-message', JITMEET_NS).getText()); } /** Asserts that no filesharing broadcast reaches `client` within the window. */ async function assertNoBroadcast(client, timeout = 1000) { try { await waitForBroadcast(client, timeout); } catch (err) { if (err.message.includes('Timeout')) { return; } throw err; } throw new Error('unexpected file-sharing broadcast'); } /** * Sets up a meeting with a participant holding a token that grants no * features, plus a second occupant that receives the room's broadcasts. */ async function setupMeeting() { const roomName = `resume-${++roomCounter}`; const roomJid = `${roomName}@${CONFERENCE}`; const focus = await joinWithFocus(roomJid); const participant = await tokenClient(roomName, tokenFor(roomName)); const other = await tokenClient(roomName, tokenFor(roomName)); clients.push(focus, participant, other); await participant.joinRoom(roomJid); await other.joinRoom(roomJid); // Baseline: without a file-upload feature the operation is refused. addFile(participant, 'baseline-1', 'file-before-resume'); assertForbidden(await waitForReply(participant, 'baseline-1'), 'file-upload must be refused before the token swap'); await assertNoBroadcast(other); return { roomName, roomJid, participant, other }; } it('ignores features from a token issued for a different room', async () => { const { roomName, participant, other } = await setupMeeting(); // Same tenant, scoped to another room, and granting file-upload there. const otherRoomToken = tokenFor(`${roomName}-other`, { 'file-upload': true }); const reconnected = participant.waitForReconnect(); participant.dropConnection({ token: otherRoomToken }); await reconnected; const session = await getSessionLive(participant.jid); assert.equal(session.jitsi_meet_room, roomName, 'room claim of a token that does not cover the joined conference must not be adopted'); assert.ok(!session.jitsi_meet_context_features?.['file-upload'], 'features of a token that does not cover the joined conference must not be adopted'); addFile(participant, 'after-1', 'file-after-resume'); assertForbidden(await waitForReply(participant, 'after-1'), 'file-upload must still be refused after resuming with a token for another room'); await assertNoBroadcast(other); }); it('adopts the claims of a rotated token issued for the same room', async () => { const { roomName, participant, other } = await setupMeeting(); // Same conference, new token: this is the token rotation the // c2s-session-updated handler exists for, and it must keep working. const rotatedToken = tokenFor(roomName, { 'file-upload': true }); const reconnected = participant.waitForReconnect(); participant.dropConnection({ token: rotatedToken }); await reconnected; const session = await getSessionLive(participant.jid); assert.equal(session.jitsi_meet_context_features['file-upload'], true, 'features of a token covering the joined conference must be adopted'); addFile(participant, 'after-2', 'file-after-resume'); const payload = await waitForBroadcast(other); assert.equal(payload.event, 'add'); assert.equal(payload.file.fileId, 'file-after-resume'); }); /** * Sets up a meeting whose moderator has moved into a breakout room: it * joins the main room, registers a breakout room, joins it and then leaves * the main room, which is what the client does when switching rooms. * * The session is left occupying only the breakout room, while the * conference its claims have to cover is still the main room. */ async function setupBreakout() { const roomName = `resume-br-${++roomCounter}`; const mainJid = `${roomName}@${CONFERENCE}`; const focus = await joinWithFocus(mainJid); const moderator = await tokenClient( roomName, mintAsapToken({ room: roomName, context: { user: { moderator: true } } })); clients.push(focus, moderator); await moderator.joinRoom(mainJid); // Register a breakout room, then let focus create it on the breakout // component (restrict_room_creation allows only Prosody admins there). moderator.sendBreakoutRoomsMessage(BREAKOUT_MUC, 'features/breakout-rooms/add', { subject: 'Breakout 1' }); const update = await moderator.waitForMessage(s2 => { const jm = s2.getChild('json-message', JITMEET_NS); return jm !== undefined && JSON.parse(jm.getText()).type === 'breakout_rooms'; }, 6000); const rooms = JSON.parse(update.getChild('json-message', JITMEET_NS).getText()).rooms; const breakoutJid = Object.values(rooms).find(r => !r.isMainRoom)?.jid; assert.ok(breakoutJid, 'breakout room JID must be in the update broadcast'); await focus.joinRoom(breakoutJid, 'focus'); const presence = await moderator.joinRoom(breakoutJid); assert.notEqual(presence.attrs.type, 'error', 'moderator must be able to join the breakout room'); // Moving into a breakout room means leaving the main room. await moderator.leaveRoom(mainJid); return { roomName, mainJid, breakoutJid, moderator }; } it('ignores a token for another room while the session sits in a breakout room', async () => { // A participant that moved into a breakout room is no longer an // occupant of the main room, but the conference it belongs to — and so // the room its token has to cover — is still the main room. const { roomName, moderator } = await setupBreakout(); const reconnected = moderator.waitForReconnect(); moderator.dropConnection({ token: tokenFor(`${roomName}-other`, { 'file-upload': true }) }); await reconnected; const session = await getSessionLive(moderator.jid); assert.equal(session.jitsi_meet_room, roomName, 'claims of a token that does not cover the main room must not be adopted in a breakout room'); assert.ok(!session.jitsi_meet_context_features?.['file-upload'], 'features of a token that does not cover the main room must not be adopted in a breakout room'); }); it('adopts the claims of a rotated token for the main room while the session sits in a breakout room', async () => { // The counterpart of the check above: the room the claims are // verified against is the main room the session joined, not the // breakout room it currently occupies, so a token that does cover // that main room has to be adopted. Without this, a rotation that // named the breakout room instead would fail closed and token // rotation would silently stop working inside breakout rooms. const { roomName, moderator } = await setupBreakout(); const rotatedToken = mintAsapToken({ room: roomName, context: { user: { moderator: true }, features: { 'file-upload': true } } }); const reconnected = moderator.waitForReconnect(); moderator.dropConnection({ token: rotatedToken }); await reconnected; const session = await getSessionLive(moderator.jid); assert.equal(session.jitsi_meet_room, roomName, 'the room claim of a token covering the main room must be adopted in a breakout room'); assert.equal(session.jitsi_meet_context_features['file-upload'], true, 'features of a token covering the main room must be adopted in a breakout room'); }); it('refreshes the claims unverified when no module answers the verification event', async () => { // mod_token_verification owns room verification: without it a join is // not room-checked either, so there is nothing to answer the // 'jitsi-verify-session-rooms' event with and the refreshed claims are // taken as they come. const { roomName, participant } = await setupMeeting(); await prosodyShell(`module:unload("token_verification", "${CONFERENCE}")`); try { const reconnected = participant.waitForReconnect(); participant.dropConnection({ token: tokenFor(`${roomName}-other`, { 'file-upload': true }) }); await reconnected; const session = await getSessionLive(participant.jid); assert.equal(session.jitsi_meet_room, `${roomName}-other`, 'with no verifier loaded the refreshed claims are adopted as-is'); } finally { await prosodyShell(`module:load("token_verification", "${CONFERENCE}")`); } }); });