Authenticate browser media with durable HttpOnly login cookies

Adapted cookie, origin, URL validation and secret handling from MikaelKW/nodecast-tv-plus. Protect API roles, revoke logins across restart, preserve existing accounts, and isolate transcode sessions by account.

Co-authored-by: Mikael <mikaelkwessel@gmail.com>
This commit is contained in:
Technomancer702
2026-10-12 16:53:15 -07:00
parent 2999d6542f
commit 1f665dcb11
24 changed files with 553 additions and 111 deletions

42
test/browser-auth.test.js Normal file
View File

@ -0,0 +1,42 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
test('browser startup uses its cookie without requiring a stored token and migrates bearer tokens once', async () => {
for (const legacy of [false, true]) {
const storage = new Map(legacy ? [['authToken', 'old-token']] : []);
const requests = [], redirects = [];
const context = vm.createContext({ console: { error() {} },
window: { location: { replace: url => redirects.push(url) } },
document: { addEventListener() {}, querySelector: () => null },
localStorage: { getItem: key => storage.get(key), removeItem: key => storage.delete(key) },
fetch: async (url, options) => { requests.push({ url, options }); return { ok: true, json: async () => ({ id: 1, role: 'viewer' }) }; } });
vm.runInContext(fs.readFileSync(path.join(__dirname, '../public/js/app.js'), 'utf8'), context);
const app = vm.runInContext('Object.create(App.prototype)', context);
app.addLogoutButton = () => {};
assert.equal(await app.checkAuth(), true);
assert.deepEqual(requests.map(r => r.url), legacy ? ['/api/auth/session', '/api/auth/me'] : ['/api/auth/me']);
assert.equal(requests.at(-1).options.credentials, 'same-origin');
assert.equal(storage.has('authToken'), false);
assert.equal(redirects.length, 0);
}
});
test('HTTP media URLs reject protected destinations and redact provider credentials', () => {
const filename = path.join(__dirname, '../server/services/urlSecurity.js');
const module = { exports: {} };
vm.runInNewContext(fs.readFileSync(filename, 'utf8'), { module, URL, process: { env: {} } });
const { validateHttpUrl, redactText, redactUrl } = module.exports;
for (const value of ['file:///etc/passwd', 'http://localhost/a', 'http://127.0.0.1/a',
'http://[::1]/a', 'http://[::ffff:127.0.0.1]/a', 'http://169.254.169.254/a',
'http://[::ffff:169.254.169.254]/a', 'http://metadata.google.internal/a', 'http://provider.test/a\n', ['http://provider.test/a']]) {
assert.throws(() => validateHttpUrl(value), undefined, String(value));
}
const url = 'https://name:secret@provider.test/live/name/password/1.ts?token=private';
assert.equal(validateHttpUrl(url), url);
assert.equal(validateHttpUrl('http://192.168.1.10/provider'), 'http://192.168.1.10/provider');
assert.equal(redactUrl(url), 'https://provider.test/…');
assert.equal(redactText(`Input ${url} failed`), 'Input [redacted URL] failed');
});