snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
This commit is contained in:
423
tests/prosody/mod_auth_token_asap_spec.js
Normal file
423
tests/prosody/mod_auth_token_asap_spec.js
Normal file
@@ -0,0 +1,423 @@
|
||||
import assert from 'assert';
|
||||
import http from 'http';
|
||||
|
||||
import { mintAsapToken } from './helpers/jwt.js';
|
||||
import { createXmppClient, joinWithFocus } from './helpers/xmpp_client.js';
|
||||
|
||||
/**
|
||||
* Fetches session-info for the given full JID.
|
||||
*
|
||||
* @param {string} jid
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
function getSessionInfo(jid) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = `http://localhost:5280/test-observer/session-info?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(`session-info returned ${res.statusCode}: ${body}`));
|
||||
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(body));
|
||||
} catch (e) {
|
||||
reject(new Error(`session-info bad JSON: ${body}`));
|
||||
}
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the main VirtualHost (RS256 / ASAP key-server auth).
|
||||
* Tokens must be signed with the test RSA private key; Prosody fetches the
|
||||
* matching public key from mod_test_observer_http's /asap-keys/ route.
|
||||
*/
|
||||
function asapClient(params) {
|
||||
return createXmppClient({ params });
|
||||
}
|
||||
|
||||
describe('mod_auth_token (ASAP / RS256)', () => {
|
||||
|
||||
const clients = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(clients.map(c => c.disconnect()));
|
||||
clients.length = 0;
|
||||
});
|
||||
|
||||
it('connects successfully with a valid RS256 token', async () => {
|
||||
const token = mintAsapToken({ room: '*' });
|
||||
const c = await asapClient({ token });
|
||||
|
||||
clients.push(c);
|
||||
assert.ok(c.jid, 'client should have a JID after connecting');
|
||||
});
|
||||
|
||||
it('rejects connection with wrong private key', async () => {
|
||||
// Sign with a freshly generated key that the server does not know.
|
||||
const { generateKeyPairSync } = await import('crypto');
|
||||
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
const wrongPem = privateKey.export({ type: 'pkcs8',
|
||||
format: 'pem' });
|
||||
const token = mintAsapToken({}, { privateKey: wrongPem });
|
||||
|
||||
await assert.rejects(
|
||||
() => asapClient({ token }),
|
||||
/not-allowed/
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects connection with expired token', async () => {
|
||||
const token = mintAsapToken({}, { expired: true });
|
||||
|
||||
await assert.rejects(
|
||||
() => asapClient({ token }),
|
||||
/not-allowed/
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects connection with not-yet-valid token (nbf in the future)', async () => {
|
||||
const token = mintAsapToken({}, { notYetValid: true });
|
||||
|
||||
await assert.rejects(
|
||||
() => asapClient({ token }),
|
||||
/not-allowed/
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects connection with wrong issuer', async () => {
|
||||
const token = mintAsapToken({ iss: 'other-app' });
|
||||
|
||||
await assert.rejects(
|
||||
() => asapClient({ token }),
|
||||
/not-allowed/
|
||||
);
|
||||
});
|
||||
|
||||
it('sets session.jitsi_meet_context_features from token context', async () => {
|
||||
const token = mintAsapToken({
|
||||
room: '*',
|
||||
context: {
|
||||
features: {
|
||||
'screen-sharing': true,
|
||||
'recording': false
|
||||
}
|
||||
}
|
||||
});
|
||||
const c = await asapClient({ 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 = mintAsapToken({ room: 'testroom' });
|
||||
const c = await asapClient({ token });
|
||||
|
||||
clients.push(c);
|
||||
const info = await getSessionInfo(c.jid);
|
||||
|
||||
assert.strictEqual(info.jitsi_meet_room, 'testroom');
|
||||
});
|
||||
|
||||
it('sets session.jitsi_meet_context_user from token context', async () => {
|
||||
const token = mintAsapToken({
|
||||
room: '*',
|
||||
context: {
|
||||
user: { id: 'user-123',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com' }
|
||||
}
|
||||
});
|
||||
const c = await asapClient({ token });
|
||||
|
||||
clients.push(c);
|
||||
const info = await getSessionInfo(c.jid);
|
||||
|
||||
assert.strictEqual(info.jitsi_meet_context_user.id, 'user-123');
|
||||
assert.strictEqual(info.jitsi_meet_context_user.name, 'Alice');
|
||||
assert.strictEqual(info.jitsi_meet_context_user.email, 'alice@example.com');
|
||||
});
|
||||
|
||||
it('sets session.jitsi_meet_context_group from token context', async () => {
|
||||
const token = mintAsapToken({
|
||||
room: '*',
|
||||
context: { group: 'test-group' }
|
||||
});
|
||||
const c = await asapClient({ token });
|
||||
|
||||
clients.push(c);
|
||||
const info = await getSessionInfo(c.jid);
|
||||
|
||||
assert.strictEqual(info.jitsi_meet_context_group, 'test-group');
|
||||
});
|
||||
|
||||
it('sets session.jitsi_meet_context_user.id from top-level user_id when context is absent', async () => {
|
||||
const token = mintAsapToken({ room: '*',
|
||||
// eslint-disable-next-line camelcase
|
||||
user_id: 'legacy-user-456' });
|
||||
const c = await asapClient({ token });
|
||||
|
||||
clients.push(c);
|
||||
const info = await getSessionInfo(c.jid);
|
||||
|
||||
assert.strictEqual(info.jitsi_meet_context_user.id, 'legacy-user-456');
|
||||
});
|
||||
|
||||
it('allows connection without token when allow_empty_token is true', async () => {
|
||||
const c = await asapClient({});
|
||||
|
||||
clients.push(c);
|
||||
assert.ok(c.jid, 'client should have a JID');
|
||||
});
|
||||
|
||||
// ── Room regex claim: invalid Lua pattern ─────────────────────────────────
|
||||
//
|
||||
// When context.room.regex == true, util.lib.lua uses the JWT room claim as
|
||||
// a Lua pattern via string:match() inside verify_room() at MUC join time.
|
||||
// An invalid Lua pattern (e.g. unbalanced parenthesis) must be rejected
|
||||
// cleanly rather than crashing Prosody with a Lua error.
|
||||
|
||||
it('does not crash on connect with invalid Lua regex room claim', async () => {
|
||||
// process_and_verify_token only stores the claim; it does not evaluate
|
||||
// the pattern. The crash path is verify_room(), called at MUC join.
|
||||
const token = mintAsapToken({
|
||||
room: '(invalid',
|
||||
context: { room: { regex: true } }
|
||||
});
|
||||
const c = await asapClient({ token });
|
||||
|
||||
clients.push(c);
|
||||
assert.ok(c.jid, 'connection must succeed: pattern not evaluated at auth time');
|
||||
});
|
||||
|
||||
it('rejects MUC join with invalid Lua regex room claim', async () => {
|
||||
const CONFERENCE = 'conference.localhost';
|
||||
const roomJid = `regex-invalid-${Date.now()}@${CONFERENCE}`;
|
||||
|
||||
// '(invalid' is an unbalanced Lua pattern capture — string:match() would
|
||||
// raise "unfinished capture" without the pcall guard in verify_room().
|
||||
const token = mintAsapToken({
|
||||
room: '(invalid',
|
||||
context: { room: { regex: true } }
|
||||
});
|
||||
|
||||
const focus = await joinWithFocus(roomJid);
|
||||
const c = await asapClient({ token });
|
||||
|
||||
clients.push(focus, c);
|
||||
|
||||
const presence = await c.joinRoom(roomJid);
|
||||
|
||||
assert.equal(presence.attrs.type, 'error', 'join must be rejected');
|
||||
assert.ok(
|
||||
presence.getChild('error')?.getChild('invalid-regex'),
|
||||
'error must contain <invalid-regex/>'
|
||||
);
|
||||
});
|
||||
|
||||
// ── Room regex claim: pattern is used as written ──────────────────────────
|
||||
//
|
||||
// Lua pattern classes are case-sensitive: %d is a digit, %D is anything
|
||||
// but a digit. verify_room() must therefore not change the case of a room
|
||||
// claim that is flagged as a pattern.
|
||||
|
||||
// Letters only, so that the room name matches %D+ ("not a digit").
|
||||
const lettersOnly = () => Date.now()
|
||||
.toString(36)
|
||||
.replace(/[0-9]/g, 'x');
|
||||
|
||||
it('accepts MUC join when the regex room claim uses an uppercase class', async () => {
|
||||
const CONFERENCE = 'conference.localhost';
|
||||
const roomJid = `rxcase${lettersOnly()}@${CONFERENCE}`;
|
||||
|
||||
const token = mintAsapToken({
|
||||
room: 'rxcase%D+',
|
||||
context: { room: { regex: true } }
|
||||
});
|
||||
|
||||
const focus = await joinWithFocus(roomJid);
|
||||
const c = await asapClient({ token });
|
||||
|
||||
clients.push(focus, c);
|
||||
|
||||
const presence = await c.joinRoom(roomJid);
|
||||
|
||||
assert.notEqual(presence.attrs.type, 'error',
|
||||
`join must succeed: ${presence.toString()}`);
|
||||
});
|
||||
|
||||
it('rejects MUC join when the room does not match the regex room claim', async () => {
|
||||
const CONFERENCE = 'conference.localhost';
|
||||
const roomJid = `rxcase${Date.now()}@${CONFERENCE}`;
|
||||
|
||||
// 'rxcase%D+' does not match a room name that ends in digits.
|
||||
const token = mintAsapToken({
|
||||
room: 'rxcase%D+',
|
||||
context: { room: { regex: true } }
|
||||
});
|
||||
|
||||
const focus = await joinWithFocus(roomJid);
|
||||
const c = await asapClient({ token });
|
||||
|
||||
clients.push(focus, c);
|
||||
|
||||
const presence = await c.joinRoom(roomJid);
|
||||
|
||||
assert.equal(presence.attrs.type, 'error', 'join must be rejected');
|
||||
assert.ok(
|
||||
presence.getChild('error')?.getChild('room-name-does-not-match'),
|
||||
'error must contain <room-name-does-not-match/>'
|
||||
);
|
||||
});
|
||||
|
||||
// ── Room regex claim: matching limits ─────────────────────────────────────
|
||||
//
|
||||
// Lua patterns backtrack, and one match blocks Prosody. verify_room()
|
||||
// therefore refuses to match a room name that is longer than
|
||||
// token_regex_room_max_length (128), or a pattern that has more than
|
||||
// token_regex_max_quantifiers (3) quantifiers. Both refusals report
|
||||
// <invalid-regex/>, which distinguishes them from a pattern that was
|
||||
// evaluated and did not match (<room-name-does-not-match/>).
|
||||
|
||||
it('accepts MUC join when the regex room claim is at the quantifier limit', async () => {
|
||||
const CONFERENCE = 'conference.localhost';
|
||||
const roomJid = `rxlimit${lettersOnly()}@${CONFERENCE}`;
|
||||
|
||||
// Three quantifiers is the default limit, and must still be allowed.
|
||||
const token = mintAsapToken({
|
||||
room: 'rxlimit%D*%D*%D+',
|
||||
context: { room: { regex: true } }
|
||||
});
|
||||
|
||||
const focus = await joinWithFocus(roomJid);
|
||||
const c = await asapClient({ token });
|
||||
|
||||
clients.push(focus, c);
|
||||
|
||||
const presence = await c.joinRoom(roomJid);
|
||||
|
||||
assert.notEqual(presence.attrs.type, 'error',
|
||||
`join must succeed: ${presence.toString()}`);
|
||||
});
|
||||
|
||||
it('rejects MUC join when the regex room claim has too many quantifiers', async () => {
|
||||
const CONFERENCE = 'conference.localhost';
|
||||
|
||||
// Under the 128-char room-name limit, so the quantifier limit is what
|
||||
// must reject this join.
|
||||
const roomJid = `${'a'.repeat(90)}${lettersOnly()}@${CONFERENCE}`;
|
||||
|
||||
// The pathological case: four quantifiers over a long run of 'a's, and
|
||||
// a trailing '%d' that a letters-only room name can never satisfy, so
|
||||
// every combination is tried before the match fails.
|
||||
const token = mintAsapToken({
|
||||
room: 'a*a*a*a*%d',
|
||||
context: { room: { regex: true } }
|
||||
});
|
||||
|
||||
const focus = await joinWithFocus(roomJid);
|
||||
const c = await asapClient({ token });
|
||||
|
||||
clients.push(focus, c);
|
||||
|
||||
const presence = await c.joinRoom(roomJid);
|
||||
|
||||
assert.equal(presence.attrs.type, 'error', 'join must be rejected');
|
||||
assert.ok(
|
||||
presence.getChild('error')?.getChild('invalid-regex'),
|
||||
`pattern must be refused unevaluated: ${presence.toString()}`
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects MUC join when the room name is too long to match a regex claim', async () => {
|
||||
const CONFERENCE = 'conference.localhost';
|
||||
|
||||
// Over the 128-char room-name limit.
|
||||
const roomJid = `${'x'.repeat(150)}${lettersOnly()}@${CONFERENCE}`;
|
||||
|
||||
// '.*' matches any room name, so without the length limit this join
|
||||
// would be allowed.
|
||||
const token = mintAsapToken({
|
||||
room: '.*',
|
||||
context: { room: { regex: true } }
|
||||
});
|
||||
|
||||
const focus = await joinWithFocus(roomJid);
|
||||
const c = await asapClient({ token });
|
||||
|
||||
clients.push(focus, c);
|
||||
|
||||
const presence = await c.joinRoom(roomJid);
|
||||
|
||||
assert.equal(presence.attrs.type, 'error', 'join must be rejected');
|
||||
assert.ok(
|
||||
presence.getChild('error')?.getChild('invalid-regex'),
|
||||
`long room name must not be matched: ${presence.toString()}`
|
||||
);
|
||||
});
|
||||
|
||||
// ── Room regex claim: token_regex_enabled = false ─────────────────────────
|
||||
//
|
||||
// conference.noregex.test has a parent host that sets
|
||||
// token_regex_enabled = false, so a room claim flagged as a pattern is
|
||||
// refused instead of matched. The same claim is accepted on
|
||||
// conference.localhost, where the option keeps its default.
|
||||
|
||||
it('rejects MUC join with a regex room claim when patterns are disabled', async () => {
|
||||
const roomName = `rxoff${lettersOnly()}`;
|
||||
const roomJid = `${roomName}@conference.noregex.test`;
|
||||
|
||||
// Matches the room name, so only the disabled option can reject it.
|
||||
const token = mintAsapToken({
|
||||
room: 'rxoff%D+',
|
||||
context: { room: { regex: true } }
|
||||
});
|
||||
|
||||
const focus = await joinWithFocus(roomJid);
|
||||
const c = await createXmppClient({ domain: 'noregex.test',
|
||||
params: { token } });
|
||||
|
||||
clients.push(focus, c);
|
||||
|
||||
const presence = await c.joinRoom(roomJid);
|
||||
|
||||
assert.equal(presence.attrs.type, 'error', 'join must be rejected');
|
||||
assert.ok(
|
||||
presence.getChild('error')?.getChild('invalid-regex'),
|
||||
`pattern must be refused: ${presence.toString()}`
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts the same regex room claim on a host where patterns are enabled', async () => {
|
||||
const roomName = `rxoff${lettersOnly()}`;
|
||||
const roomJid = `${roomName}@conference.localhost`;
|
||||
|
||||
const token = mintAsapToken({
|
||||
room: 'rxoff%D+',
|
||||
context: { room: { regex: true } }
|
||||
});
|
||||
|
||||
const focus = await joinWithFocus(roomJid);
|
||||
const c = await asapClient({ token });
|
||||
|
||||
clients.push(focus, c);
|
||||
|
||||
const presence = await c.joinRoom(roomJid);
|
||||
|
||||
assert.notEqual(presence.attrs.type, 'error',
|
||||
`join must succeed: ${presence.toString()}`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user