|
| 1 | +// TURN credentials plugin over a real JSS from npm: draft-uberti REST shape, |
| 2 | +// HMAC correctness against the shared secret, expiry math, user tags, |
| 3 | +// env-variable config fallback, auth gating, and loud activation failures. |
| 4 | +import { describe, it, before, after } from 'node:test'; |
| 5 | +import assert from 'node:assert'; |
| 6 | +import crypto from 'node:crypto'; |
| 7 | +import path from 'node:path'; |
| 8 | +import { fileURLToPath } from 'node:url'; |
| 9 | +import { startJss } from '../helpers.js'; |
| 10 | + |
| 11 | +const __dirname = path.dirname(fileURLToPath(new URL(import.meta.url))); |
| 12 | +const MODULE = path.join(__dirname, 'plugin.js'); |
| 13 | +const SECRET = 'test-static-auth-secret'; |
| 14 | +const URIS = ['turn:relay.example:3478?transport=udp', 'turns:relay.example:5349']; |
| 15 | + |
| 16 | +const hmac = (username) => |
| 17 | + crypto.createHmac('sha1', SECRET).update(username).digest('base64'); |
| 18 | + |
| 19 | +describe('turn credentials plugin', () => { |
| 20 | + let jss; |
| 21 | + before(async () => { |
| 22 | + jss = await startJss({ |
| 23 | + plugins: [{ module: MODULE, prefix: '/turn', config: { secret: SECRET, uris: URIS, ttl: 600 } }], |
| 24 | + }); |
| 25 | + }); |
| 26 | + after(async () => { if (jss) await jss.close(); }); |
| 27 | + |
| 28 | + it('mints draft-shaped credentials coturn will verify', async () => { |
| 29 | + const res = await fetch(`${jss.base}/turn/credentials`); |
| 30 | + assert.strictEqual(res.status, 200); |
| 31 | + assert.strictEqual(res.headers.get('cache-control'), 'no-store'); |
| 32 | + const body = await res.json(); |
| 33 | + assert.deepStrictEqual(body.uris, URIS); |
| 34 | + assert.strictEqual(body.ttl, 600); |
| 35 | + // username is a bare unix expiry ~ttl from now |
| 36 | + assert.match(body.username, /^\d+$/); |
| 37 | + const skew = Number(body.username) - (Math.floor(Date.now() / 1000) + 600); |
| 38 | + assert.ok(Math.abs(skew) < 30, `expiry off by ${skew}s`); |
| 39 | + // password = base64(HMAC-SHA1(secret, username)) — coturn's check, replayed |
| 40 | + assert.strictEqual(body.password, hmac(body.username)); |
| 41 | + // drop-in iceServers entry |
| 42 | + assert.deepStrictEqual(body.iceServers, [{ urls: URIS, username: body.username, credential: body.password }]); |
| 43 | + }); |
| 44 | + |
| 45 | + it('embeds a user tag after the expiry, colon-delimited', async () => { |
| 46 | + const res = await fetch(`${jss.base}/turn/credentials?user=mesh-tab.1`); |
| 47 | + const body = await res.json(); |
| 48 | + assert.match(body.username, /^\d+:mesh-tab\.1$/); |
| 49 | + assert.strictEqual(body.password, hmac(body.username)); |
| 50 | + }); |
| 51 | + |
| 52 | + it('rejects tags outside [A-Za-z0-9._-]{1,64}', async () => { |
| 53 | + const res = await fetch(`${jss.base}/turn/credentials?user=${encodeURIComponent('a:b c')}`); |
| 54 | + assert.strictEqual(res.status, 400); |
| 55 | + }); |
| 56 | + |
| 57 | + it('reads secret and uris from the environment when config carries none', async () => { |
| 58 | + process.env.TURN_STATIC_AUTH_SECRET = SECRET; |
| 59 | + process.env.TURN_URIS = URIS.join(', '); |
| 60 | + let envJss; |
| 61 | + try { |
| 62 | + envJss = await startJss({ plugins: [{ module: MODULE, prefix: '/turn-env' }] }); |
| 63 | + const body = await (await fetch(`${envJss.base}/turn-env/credentials`)).json(); |
| 64 | + assert.deepStrictEqual(body.uris, URIS); |
| 65 | + assert.strictEqual(body.password, hmac(body.username)); |
| 66 | + } finally { |
| 67 | + delete process.env.TURN_STATIC_AUTH_SECRET; |
| 68 | + delete process.env.TURN_URIS; |
| 69 | + if (envJss) await envJss.close(); |
| 70 | + } |
| 71 | + }); |
| 72 | + |
| 73 | + it('requireAuth turns anonymous minting into a 401', async () => { |
| 74 | + const gated = await startJss({ |
| 75 | + plugins: [{ module: MODULE, prefix: '/turn-gated', config: { secret: SECRET, uris: URIS, requireAuth: true } }], |
| 76 | + }); |
| 77 | + try { |
| 78 | + const res = await fetch(`${gated.base}/turn-gated/credentials`); |
| 79 | + assert.strictEqual(res.status, 401); |
| 80 | + } finally { await gated.close(); } |
| 81 | + }); |
| 82 | + |
| 83 | + it('a missing secret fails listen() loudly', async () => { |
| 84 | + await assert.rejects( |
| 85 | + () => startJss({ plugins: [{ module: MODULE, prefix: '/turn-bad', config: { uris: URIS } }] }), |
| 86 | + /no secret/, |
| 87 | + ); |
| 88 | + }); |
| 89 | + |
| 90 | + it('missing uris fail listen() loudly', async () => { |
| 91 | + await assert.rejects( |
| 92 | + () => startJss({ plugins: [{ module: MODULE, prefix: '/turn-bad2', config: { secret: SECRET } }] }), |
| 93 | + /no TURN uris/, |
| 94 | + ); |
| 95 | + }); |
| 96 | +}); |
0 commit comments