| [af2cc73] | 1 | // OpenWebAuth over HTTP: de vindbaarheid, de poort van het token-endpoint, en
|
|---|
| 2 | // de impersonatie-verdediging.
|
|---|
| 3 | //
|
|---|
| 4 | // Die laatste is de belangrijkste toets in dit bestand. De FEP beschrijft hem
|
|---|
| 5 | // zo: Mallory maakt een link naar ONZE site met ?zid=bob@elders, klikt hem zelf,
|
|---|
| 6 | // en komt terug met een token dat MALLORY zegt. Wie `zid` gelooft in plaats van
|
|---|
| 7 | // het ingewisselde `owt`, laat Mallory als Bob binnen.
|
|---|
| 8 | import { test } from 'node:test';
|
|---|
| 9 | import assert from 'node:assert/strict';
|
|---|
| 10 | import express from 'express';
|
|---|
| 11 |
|
|---|
| 12 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 13 | process.env.PUBLIC_BASE_URL = 'https://test.example';
|
|---|
| 14 |
|
|---|
| 15 | const dbMod = await import('../src/config/database.js');
|
|---|
| 16 | const db = dbMod.default;
|
|---|
| 17 | dbMod.initializeDatabase();
|
|---|
| 18 | const OWA = await import('../src/services/OpenWebAuthService.js');
|
|---|
| 19 | const apRoutes = (await import('../src/routes/activitypub.js')).default;
|
|---|
| 20 | const owaMod = await import('../src/routes/openwebauth.js');
|
|---|
| 21 |
|
|---|
| 22 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
|
|---|
| 23 | .run('u1', 'u1', 'u1@t', 'x', 'god');
|
|---|
| 24 | db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary, created_at) VALUES (?,?,?,?,1,?)')
|
|---|
| 25 | .run('s1', 'kid', 'kid', 'u1', '2026-01-01 00:00:00');
|
|---|
| 26 |
|
|---|
| 27 | const ACTOR = 'https://elders.example/users/mee';
|
|---|
| 28 | const SLACHTOFFER = 'https://elders.example/users/bob';
|
|---|
| 29 |
|
|---|
| 30 | // Eén app met een sessie-nep: we willen hier de middleware toetsen, niet
|
|---|
| 31 | // express-session.
|
|---|
| 32 | const app = express();
|
|---|
| 33 | app.use((req, _res, next) => { req.session = app.locals.sessie; next(); });
|
|---|
| 34 | app.use(apRoutes);
|
|---|
| 35 | app.use(owaMod.default);
|
|---|
| 36 | app.use(owaMod.owaMiddleware);
|
|---|
| 37 | app.get('/een-bericht', (req, res) => res.json({ owa: (req.session && req.session.owa) || null }));
|
|---|
| 38 |
|
|---|
| 39 | const server = app.listen(0);
|
|---|
| 40 | await new Promise((r) => server.once('listening', r));
|
|---|
| 41 | const port = server.address().port;
|
|---|
| 42 | test.after(() => server.close());
|
|---|
| 43 |
|
|---|
| 44 | const haal = (pad, opts) => fetch(`http://127.0.0.1:${port}${pad}`, { redirect: 'manual', ...opts });
|
|---|
| 45 |
|
|---|
| 46 | // ── vindbaarheid ──────────────────────────────────────────────────────────
|
|---|
| 47 |
|
|---|
| 48 | test('de wortel wijst een home instance naar ons token-endpoint', async () => {
|
|---|
| 49 | const r = await haal('/.well-known/webfinger?resource=' + encodeURIComponent('https://test.example/'));
|
|---|
| 50 | assert.equal(r.status, 200);
|
|---|
| 51 | const jrd = await r.json();
|
|---|
| 52 | const link = jrd.links.find((l) => l.rel === 'http://purl.org/openwebauth/v1');
|
|---|
| 53 | assert.ok(link, 'de rel uit de FEP staat erin');
|
|---|
| 54 | assert.equal(link.href, 'https://test.example/owa/token');
|
|---|
| 55 | });
|
|---|
| 56 |
|
|---|
| 57 | test('en een ACTOR-uri blijft een 400, zoals eerder besloten', async () => {
|
|---|
| 58 | // Dit is geen detail: test/webfinger-bare-host.test.js legt het vast, en de
|
|---|
| 59 | // wortel-uitzondering mocht die keuze niet stiekem omdraaien.
|
|---|
| 60 | const r = await haal('/.well-known/webfinger?resource=' + encodeURIComponent('https://test.example/ap/users/kid'));
|
|---|
| 61 | assert.equal(r.status, 400);
|
|---|
| 62 | });
|
|---|
| 63 |
|
|---|
| 64 | test('een wortel van een ANDERE host is niet van ons', async () => {
|
|---|
| 65 | const r = await haal('/.well-known/webfinger?resource=' + encodeURIComponent('https://ergens-anders.example/'));
|
|---|
| 66 | assert.equal(r.status, 400);
|
|---|
| 67 | });
|
|---|
| 68 |
|
|---|
| 69 | // ── het token-endpoint ────────────────────────────────────────────────────
|
|---|
| 70 |
|
|---|
| 71 | test('zonder handtekening geen token', async () => {
|
|---|
| 72 | // De hele waarde van dit endpoint zit in de handtekening: wie hem niet zet,
|
|---|
| 73 | // bewijst niets en krijgt niets.
|
|---|
| 74 | for (const method of ['GET', 'POST']) {
|
|---|
| 75 | const r = await haal('/owa/token', { method });
|
|---|
| 76 | assert.equal(r.status, 401, method);
|
|---|
| 77 | assert.deepEqual(await r.json(), { success: false });
|
|---|
| 78 | }
|
|---|
| 79 | });
|
|---|
| 80 |
|
|---|
| 81 | test('een verzonnen handtekening ook niet', async () => {
|
|---|
| 82 | const r = await haal('/owa/token', {
|
|---|
| 83 | headers: { signature: 'keyId="https://elders.example/users/mee#main-key",signature="bm9wZQ=="' },
|
|---|
| 84 | });
|
|---|
| 85 | assert.equal(r.status, 401);
|
|---|
| 86 | });
|
|---|
| 87 |
|
|---|
| [d85b66f] | 88 | test('de handtekening mag in Authorization staan, zoals de FEP voorschrijft', async () => {
|
|---|
| 89 | // Hubzilla, (streams) en Forte sturen `Authorization: Signature ...`; de rest
|
|---|
| 90 | // van de fediverse gebruikt de `Signature`-header. Zonder vertaling zou elke
|
|---|
| 91 | // ECHTE client hier een 401 krijgen terwijl hij alles goed deed -- en zou pas
|
|---|
| 92 | // de eerste interop-proef dat aan het licht brengen.
|
|---|
| 93 | //
|
|---|
| 94 | // We toetsen hier dat de header wordt GELEZEN, niet dat een verzonnen
|
|---|
| 95 | // handtekening slaagt: die hoort nog steeds te falen. Het verschil zit hem in
|
|---|
| 96 | // hoe ver het komt -- een genegeerde header en een afgekeurde handtekening
|
|---|
| 97 | // zien er van buiten hetzelfde uit, dus kijken we naar de ondertekenaar die
|
|---|
| 98 | // wél wordt opgezocht.
|
|---|
| 99 | const r = await haal('/owa/token', {
|
|---|
| 100 | headers: { authorization: 'Signature keyId="https://elders.example/users/mee#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="bm9wZQ=="' },
|
|---|
| 101 | });
|
|---|
| 102 | assert.equal(r.status, 401, 'een verzonnen handtekening blijft een 401');
|
|---|
| 103 | assert.deepEqual(await r.json(), { success: false });
|
|---|
| 104 | });
|
|---|
| 105 |
|
|---|
| [af2cc73] | 106 | // ── impersonatie ──────────────────────────────────────────────────────────
|
|---|
| 107 |
|
|---|
| 108 | test('?owt= bepaalt wie je bent', async () => {
|
|---|
| 109 | app.locals.sessie = {};
|
|---|
| 110 | const t = OWA.issueToken(ACTOR);
|
|---|
| 111 | const r = await haal('/een-bericht?owt=' + encodeURIComponent(t));
|
|---|
| 112 | assert.equal(r.status, 302, 'het token wordt uit de URL gehaald');
|
|---|
| 113 | assert.equal(r.headers.get('location'), '/een-bericht', 'en laat niets achter in de adresbalk');
|
|---|
| 114 | assert.equal(app.locals.sessie.owa.actor, ACTOR);
|
|---|
| 115 | });
|
|---|
| 116 |
|
|---|
| 117 | test('?zid= bepaalt NIETS -- dit is de impersonatie-aanval uit de FEP', async () => {
|
|---|
| 118 | app.locals.sessie = {};
|
|---|
| 119 | // Mallory wijst met zid naar Bob. Er mag hier geen identiteit uit ontstaan.
|
|---|
| 120 | const r = await haal('/een-bericht?zid=' + encodeURIComponent('bob@elders.example'));
|
|---|
| 121 | assert.ok(r.status === 302 || r.status === 200, 'hooguit een doorverwijzing');
|
|---|
| 122 | assert.equal(app.locals.sessie.owa, undefined, 'zid logt niemand in');
|
|---|
| 123 | });
|
|---|
| 124 |
|
|---|
| 125 | test('en zid naast een geldig owt verandert de uitkomst niet', async () => {
|
|---|
| 126 | app.locals.sessie = {};
|
|---|
| 127 | const t = OWA.issueToken(ACTOR); // dit is Mallory's eigen token
|
|---|
| 128 | const r = await haal(`/een-bericht?owt=${encodeURIComponent(t)}&zid=${encodeURIComponent('bob@elders.example')}`);
|
|---|
| 129 | assert.equal(r.status, 302);
|
|---|
| 130 | assert.equal(app.locals.sessie.owa.actor, ACTOR, 'het token wint, niet de claim');
|
|---|
| 131 | assert.notEqual(app.locals.sessie.owa.actor, SLACHTOFFER);
|
|---|
| 132 | assert.equal(r.headers.get('location'), '/een-bericht', 'en zid gaat ook de URL uit');
|
|---|
| 133 | });
|
|---|
| 134 |
|
|---|
| 135 | test('een opgebruikt token logt niemand in', async () => {
|
|---|
| 136 | app.locals.sessie = {};
|
|---|
| 137 | const t = OWA.issueToken(ACTOR);
|
|---|
| 138 | await haal('/een-bericht?owt=' + encodeURIComponent(t));
|
|---|
| 139 | app.locals.sessie = {};
|
|---|
| 140 | await haal('/een-bericht?owt=' + encodeURIComponent(t));
|
|---|
| 141 | assert.equal(app.locals.sessie.owa, undefined, 'de tweede keer is hij niets waard');
|
|---|
| 142 | });
|
|---|
| 143 |
|
|---|
| 144 | // ── uitloggen ─────────────────────────────────────────────────────────────
|
|---|
| 145 |
|
|---|
| 146 | test('uitloggen laat een lokale sessie met rust', async () => {
|
|---|
| 147 | app.locals.sessie = { owa: { actor: ACTOR }, user: { id: 'u1' } };
|
|---|
| 148 | await haal('/owa/logout');
|
|---|
| 149 | assert.equal(app.locals.sessie.owa, undefined, 'de gast is weg');
|
|---|
| 150 | assert.deepEqual(app.locals.sessie.user, { id: 'u1' }, 'de lokale gebruiker niet');
|
|---|
| 151 | });
|
|---|