Skip to content

Commit 7155eee

Browse files
turn: TURN REST credentials plugin (draft-uberti-behave-turn-rest-00) — the JSS half of a coturn deployment. Mints time-limited HMAC-SHA1 credentials against coturn's static-auth-secret so no static TURN password ships in a public page; draft-shaped response plus a drop-in iceServers array; secret/uris from config or env (CLI --plugin entries carry no config); optional requireAuth gating; activation fails listen() loudly without secret or uris
1 parent 5a0b2d7 commit 7155eee

3 files changed

Lines changed: 238 additions & 0 deletions

File tree

turn/README.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# turn — TURN REST credentials
2+
3+
The JSS half of a [coturn](https://github.com/coturn/coturn) deployment.
4+
coturn relays WebRTC traffic for peers whose NATs defeat STUN; this plugin
5+
mints the **time-limited credentials** (draft-uberti-behave-turn-rest-00)
6+
browsers use to reach it, so no static TURN password ever ships in a public
7+
page — a leaked credential expires by itself.
8+
9+
```js
10+
plugins: [{ module: 'turn/plugin.js', prefix: '/.turn',
11+
config: { uris: ['turn:pod.example:3478?transport=udp',
12+
'turns:pod.example:5349'],
13+
ttl: 3600 } }]
14+
```
15+
16+
## Endpoint
17+
18+
`GET <prefix>/credentials[?user=<tag>]`
19+
20+
```json
21+
{ "username": "1784495000",
22+
"password": "base64(HMAC-SHA1(secret, username))",
23+
"ttl": 3600,
24+
"uris": ["turn:pod.example:3478?transport=udp"],
25+
"iceServers": [{ "urls": [""], "username": "", "credential": "" }] }
26+
```
27+
28+
`username` is the unix expiry, optionally `:tagged` (`[A-Za-z0-9._-]{1,64}`)
29+
for per-client attribution in coturn's logs. `iceServers` drops straight
30+
into `new RTCPeerConnection({ iceServers })`. Responses are `no-store`.
31+
32+
## Config
33+
34+
| key | default | notes |
35+
|---|---|---|
36+
| `secret` || must equal coturn's `static-auth-secret`; falls back to the env var below |
37+
| `secretEnv` | `TURN_STATIC_AUTH_SECRET` | env fallback — a CLI `--plugin module@prefix` entry carries no config |
38+
| `uris` | env `TURN_URIS` (comma-separated) | `turn:`/`turns:` URIs handed to clients |
39+
| `ttl` | `3600` | credential lifetime, seconds |
40+
| `requireAuth` | `false` | gate minting on `api.auth.getAgent` (401 anonymous) |
41+
42+
Activation **fails listen() loudly** without a secret or uris.
43+
44+
## The coturn side
45+
46+
```
47+
use-auth-secret
48+
static-auth-secret=<same secret>
49+
realm=pod.example
50+
fingerprint
51+
no-multicast-peers
52+
# keep the relay off your internal network
53+
denied-peer-ip=10.0.0.0-10.255.255.255
54+
denied-peer-ip=172.16.0.0-172.31.255.255
55+
denied-peer-ip=192.168.0.0-192.168.255.255
56+
denied-peer-ip=127.0.0.0-127.255.255.255
57+
```
58+
59+
## Notes
60+
61+
- Anonymous minting is the default on purpose: expiry makes handing
62+
credentials to any visitor survivable, and mesh pages run accountless.
63+
`requireAuth` exists for relays that should serve known agents only.
64+
- The relay sees IPs and encrypted DTLS bytes, never plaintext content;
65+
ICE prefers direct paths, so the relay carries traffic only for pairs
66+
that could not connect otherwise.
67+
- `turns:` on TCP 443 is what defeats strict firewalls, but 443 usually
68+
belongs to the pod — a second IP (or 5349 and accepting the loss) is the
69+
deployment decision this plugin stays out of.

turn/plugin.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// TURN REST credentials (draft-uberti-behave-turn-rest-00) as a #206 loader
2+
// plugin — the JSS half of a coturn deployment. coturn relays media; this
3+
// endpoint mints the time-limited credentials browsers use to reach it, so
4+
// no static TURN password ever ships in a public page (a leaked credential
5+
// expires by itself).
6+
//
7+
// plugins: [{ module: 'turn/plugin.js', prefix: '/.turn',
8+
// config: { uris: ['turn:pod.example:3478?transport=udp',
9+
// 'turns:pod.example:5349'],
10+
// ttl: 3600 } }]
11+
//
12+
// Serves `GET <prefix>/credentials[?user=<tag>]` →
13+
// { username, password, ttl, uris, // the draft's shape
14+
// iceServers: [{ urls, username, credential }] // drop-in for RTCPeerConnection
15+
// }
16+
// where username = "<unix-expiry>[:<tag>]" and
17+
// password = base64(HMAC-SHA1(secret, username)) — exactly what coturn
18+
// verifies under `use-auth-secret` / `static-auth-secret`.
19+
//
20+
// The secret comes from config.secret, or the environment variable named by
21+
// config.secretEnv (default TURN_STATIC_AUTH_SECRET) — the env path exists
22+
// because a CLI `--plugin module@prefix` entry carries no config object.
23+
// `uris` may likewise come from a comma-separated TURN_URIS. Activation
24+
// fails loudly when either is missing: a server that would mint credentials
25+
// nobody can use (or sign them with '') must not boot quietly.
26+
//
27+
// Minting is anonymous by default — the point of expiry is that handing
28+
// creds to any visitor is survivable, and the mesh pages that need them run
29+
// without accounts. Set config.requireAuth to gate on api.auth.getAgent
30+
// (401 for anonymous) when the relay should serve only known agents.
31+
32+
import crypto from 'node:crypto';
33+
34+
const DEFAULT_TTL = 3600; // seconds; short — clients re-mint per session
35+
const USER_TAG = /^[A-Za-z0-9._-]{1,64}$/;
36+
37+
export async function activate(api) {
38+
const cfg = api.config || {};
39+
const secret = cfg.secret
40+
|| process.env[cfg.secretEnv || 'TURN_STATIC_AUTH_SECRET']
41+
|| '';
42+
if (!secret) {
43+
throw new Error('turn plugin: no secret — set config.secret or the '
44+
+ `${cfg.secretEnv || 'TURN_STATIC_AUTH_SECRET'} environment variable`);
45+
}
46+
const uris = (Array.isArray(cfg.uris) && cfg.uris.length ? cfg.uris
47+
: String(process.env.TURN_URIS || '').split(',').map((s) => s.trim()).filter(Boolean));
48+
if (!uris.length) {
49+
throw new Error('turn plugin: no TURN uris — set config.uris or TURN_URIS');
50+
}
51+
const ttl = Number.isInteger(cfg.ttl) && cfg.ttl > 0 ? cfg.ttl : DEFAULT_TTL;
52+
const requireAuth = !!cfg.requireAuth;
53+
54+
api.fastify.get(`${api.prefix}/credentials`, async (request, reply) => {
55+
if (requireAuth) {
56+
const agent = await api.auth.getAgent(request);
57+
if (!agent) return reply.code(401).send({ error: 'Unauthorized' });
58+
}
59+
const tag = request.query?.user;
60+
if (tag !== undefined && !USER_TAG.test(String(tag))) {
61+
return reply.code(400).send({ error: 'BadRequest', message: 'user must match [A-Za-z0-9._-]{1,64}' });
62+
}
63+
const username = `${Math.floor(Date.now() / 1000) + ttl}${tag ? ':' + tag : ''}`;
64+
const password = crypto.createHmac('sha1', secret).update(username).digest('base64');
65+
reply.header('cache-control', 'no-store'); // every response is a fresh, expiring grant
66+
return {
67+
username, password, ttl, uris,
68+
iceServers: [{ urls: uris, username, credential: password }],
69+
};
70+
});
71+
72+
api.log.info(`turn: minting ${ttl}s credentials at ${api.prefix}/credentials for ${uris.length} uri(s)`);
73+
}

turn/test.js

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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

Comments
 (0)