424 lines
15 KiB
JavaScript
424 lines
15 KiB
JavaScript
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()}`);
|
|
});
|
|
});
|