Fix safe login submission, mobile fullscreen, and OIDC discovery

This commit is contained in:
Technomancer702
2026-10-13 18:31:48 -07:00
parent b85b160013
commit ce122eb34b
16 changed files with 479 additions and 72 deletions

View File

@ -24,7 +24,7 @@ test('both subtitle menus select real HLS cues, discard stale tracks and preserv
assert.equal(generated.status, 0, generated.stderr);
const root = path.join(__dirname, '../public');
const scripts = ['/js/components/CaptionTracks.js', '/js/components/HlsAudioRecovery.js', '/js/components/VideoPlayer.js', '/js/pages/WatchPage.js'];
const scripts = ['/js/components/CaptionTracks.js', '/js/components/HlsAudioRecovery.js', '/js/components/PlayerFullscreen.js', '/js/components/VideoPlayer.js', '/js/pages/WatchPage.js'];
const master = names => '#EXTM3U\n' + names.map(([name, extra, language = 'en'], index) =>
`#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="${name}",LANGUAGE="${language}",DEFAULT=NO,AUTOSELECT=NO,${extra}URI="/sub${index}.m3u8"\n`).join('') +
'#EXT-X-STREAM-INF:BANDWIDTH=100000,CODECS="avc1.42c00a",SUBTITLES="subs"\n/video.m3u8\n';

View File

@ -37,7 +37,7 @@ test('Chromium plays program/ad/program audio with decoder resets at played boun
manifest.push('#EXT-X-ENDLIST');
const root = path.join(__dirname, '../public');
const scripts = ['/js/components/CaptionTracks.js', '/js/components/HlsAudioRecovery.js',
'/js/components/VideoPlayer.js', '/js/pages/WatchPage.js'];
'/js/components/PlayerFullscreen.js', '/js/components/VideoPlayer.js', '/js/pages/WatchPage.js'];
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://fixture.test');
const send = (type, data) => { res.writeHead(200, { 'Content-Type': type }); res.end(data); };

View File

@ -0,0 +1,170 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const http = require('node:http');
const vm = require('node:vm');
function fullscreenFixture(name) {
const window = {}, document = { querySelector: () => player.container };
const helper = { exports: {} };
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../public/js/components/PlayerFullscreen.js'), 'utf8'), {
module: helper, console: { error() {} }, document
});
const file = name === 'VideoPlayer' ? 'components/VideoPlayer.js' : 'pages/WatchPage.js';
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../public/js', file), 'utf8'), {
window, document, PlayerFullscreen: helper.exports
});
const player = Object.create(window[name].prototype);
player.container = {}; player.video = {};
return { player, document };
}
for (const name of ['VideoPlayer', 'WatchPage']) {
test(`${name} supports standard, prefixed and native-video fullscreen without invoking disabled APIs`, async () => {
const scenarios = [
{ doc: { fullscreenEnabled: true }, container: 'requestFullscreen', expected: 'standard' },
{ doc: { fullscreenEnabled: false, webkitFullscreenEnabled: true }, container: 'webkitRequestFullscreen', expected: 'prefixed' },
{ doc: { fullscreenEnabled: false, webkitFullscreenEnabled: false }, native: true, expected: 'native' },
{ doc: {}, native: true, expected: 'native' },
{ doc: { fullscreenElement: {} }, exit: 'exitFullscreen', expected: 'exit' },
{ doc: { webkitFullscreenElement: {} }, exit: 'webkitExitFullscreen', expected: 'exit' },
{ doc: {}, nativeExit: true, expected: 'exit-native' }
];
for (const scenario of scenarios) {
const { player, document } = fullscreenFixture(name), calls = [];
Object.assign(document, scenario.doc);
if (scenario.container) player.container[scenario.container] = () => { calls.push(scenario.expected); return Promise.resolve(); };
if (scenario.exit) document[scenario.exit] = () => { calls.push('exit'); };
if (scenario.native) {
player.container.requestFullscreen = () => { throw Error('Disabled element API used'); };
player.container.webkitRequestFullscreen = () => { throw Error('Disabled prefixed API used'); };
player.video.webkitEnterFullscreen = () => { calls.push('native'); };
}
if (scenario.nativeExit) Object.assign(player.video, { webkitDisplayingFullscreen: true, webkitExitFullscreen() { calls.push('exit-native'); } });
const result = player.toggleFullscreen();
assert.deepEqual(calls, [scenario.expected], 'invoke during the originating user gesture');
assert.equal(await result, true);
}
const rejected = fullscreenFixture(name);
rejected.document.fullscreenEnabled = true;
rejected.player.container.requestFullscreen = () => Promise.reject(Error('Fullscreen denied'));
assert.equal(await rejected.player.toggleFullscreen(), false, 'handle rejected promises');
const missing = fullscreenFixture(name);
assert.equal(await missing.player.toggleFullscreen(), false, 'missing API is harmless');
});
}
const browserEnabled = process.env.NODECAST_BROWSER_TESTS === '1';
test('login prevents native GET credential leaks after missing or failed scripts and still supports setup/login',
{ skip: browserEnabled ? false : 'Browser tests enabled in CI', timeout: 45000 }, async t => {
const { chromium } = require('playwright');
const source = fs.readFileSync(path.join(__dirname, '../public/login.html'), 'utf8');
const requests = [];
let mode = 'normal', setup = true;
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://fixture');
if (req.method === 'POST') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => {
requests.push({ method: req.method, url: req.url, body, type: req.headers['content-type'] });
res.setHeader('Content-Type', 'application/json');
if (!req.headers['content-type']?.startsWith('application/json')) { res.writeHead(400); res.end('{"error":"JavaScript required"}'); return; }
if (url.pathname === '/api/auth/setup') setup = false;
res.writeHead(url.pathname.endsWith('/setup') ? 201 : 200); res.end('{}');
});
return;
}
requests.push({ method: req.method, url: req.url });
if (url.pathname === '/api/auth/setup-required') {
res.setHeader('Content-Type', 'application/json');
if (mode === 'unavailable') { res.writeHead(503); res.end('{"error":"Unavailable"}'); }
else res.end(JSON.stringify({ setupRequired: setup }));
return;
}
res.setHeader('Content-Type', 'text/html');
if (url.pathname === '/') { res.end('<p>Main app</p>'); return; }
const html = mode === 'broken' ? source.replace('// Start SSO Login', "throw Error('Fixture script failed');\n // Start SSO Login") : source;
res.end(html);
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise(resolve => { server.close(resolve); server.closeAllConnections(); }));
const base = `http://127.0.0.1:${server.address().port}`;
const browser = await chromium.launch({ headless: true, executablePath: process.env.NODECAST_BROWSER_EXECUTABLE || undefined });
t.after(() => browser.close());
for (const scenario of ['no-js', 'broken']) {
mode = scenario === 'broken' ? 'broken' : 'normal';
const context = await browser.newContext({ javaScriptEnabled: scenario !== 'no-js' });
const page = await context.newPage();
page.on('pageerror', () => {});
await page.route('**/*', route => route.request().url().startsWith(base) ? route.continue() : route.abort());
await page.goto(base + '/login.html');
await page.fill('#username', 'fixture-user'); await page.fill('#password', 'fixture-password');
assert.equal(await page.locator('#submit-btn').isDisabled(), true);
await page.locator('#password').press('Enter');
assert.equal(new URL(page.url()).pathname, '/login.html');
// Even direct native form.submit() must put credentials in a POST body, never a URL.
const sent = page.waitForRequest(request => request.method() === 'POST');
await page.evaluate(() => document.getElementById('login-form').submit());
const request = await sent;
assert.equal(new URL(request.url()).pathname, '/api/auth/login');
assert.equal(new URL(request.url()).search, '');
assert.equal(new URLSearchParams(request.postData()).get('username'), 'fixture-user');
await context.close();
}
mode = 'unavailable';
const failed = await browser.newPage();
await failed.route('**/*', route => route.request().url().startsWith(base) ? route.continue() : route.abort());
await failed.goto(base + '/login.html');
await failed.waitForFunction(() => document.querySelector('#error-message').textContent.includes('Reload'));
assert.equal(await failed.locator('#submit-btn').isDisabled(), true);
await failed.close();
mode = 'normal';
for (const endpoint of ['/api/auth/setup', '/api/auth/login']) {
const page = await browser.newPage();
await page.route('**/*', route => route.request().url().startsWith(base) ? route.continue() : route.abort());
await page.goto(base + '/login.html');
await page.waitForFunction(() => !document.querySelector('#submit-btn').disabled);
assert.equal(await page.locator('#submit-btn').textContent(), endpoint.endsWith('setup') ? 'Create Account' : 'Sign In');
await page.fill('#username', 'fixture-user'); await page.fill('#password', 'fixture-password');
const sent = page.waitForRequest(request => request.url() === base + endpoint && request.method() === 'POST');
await page.click('#submit-btn');
const request = await sent;
assert.deepEqual(JSON.parse(request.postData()), { username: 'fixture-user', password: 'fixture-password' });
await page.waitForURL(base + '/'); await page.close();
}
assert.equal(requests.some(request => /[?&](username|password)=/.test(request.url)), false);
});
test('both real Chromium player buttons enter and exit element fullscreen',
{ skip: browserEnabled ? false : 'Browser tests enabled in CI', timeout: 30000 }, async t => {
const { chromium } = require('playwright');
const scripts = ['/js/components/PlayerFullscreen.js', '/js/components/VideoPlayer.js', '/js/pages/WatchPage.js'];
const root = path.join(__dirname, '../public');
const server = http.createServer((req, res) => {
if (scripts.includes(req.url)) { res.setHeader('Content-Type', 'application/javascript'); res.end(fs.readFileSync(path.join(root, req.url))); return; }
res.setHeader('Content-Type', 'text/html');
res.end(`<div class="watch-video-section"><video></video><button>Fullscreen</button></div>${scripts.map(src => `<script src="${src}"></script>`).join('')}`);
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise(resolve => { server.close(resolve); server.closeAllConnections(); }));
const browser = await chromium.launch({ headless: true, executablePath: process.env.NODECAST_BROWSER_EXECUTABLE || undefined });
t.after(() => browser.close());
for (const name of ['VideoPlayer', 'WatchPage']) {
const page = await browser.newPage(), errors = [];
page.on('pageerror', error => errors.push(error.message));
await page.goto(`http://127.0.0.1:${server.address().port}`);
await page.evaluate(name => {
const player = Object.create(window[name].prototype);
player.video = document.querySelector('video'); player.container = document.querySelector('.watch-video-section');
document.querySelector('button').addEventListener('click', () => player.toggleFullscreen());
}, name);
await page.click('button');
await page.waitForFunction(() => document.fullscreenElement?.classList.contains('watch-video-section'));
await page.click('button');
await page.waitForFunction(() => document.fullscreenElement === null);
assert.deepEqual(errors, []); await page.close();
}
});

155
test/oidc-discovery.test.js Normal file
View File

@ -0,0 +1,155 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawn } = require('node:child_process');
const { discoverOidcEndpoints } = require('../server/services/oidcDiscovery');
const issuer = 'https://idp.fixture.test/application/o/nodecast/';
const metadata = { issuer, authorization_endpoint: 'https://idp.fixture.test/application/o/authorize/',
token_endpoint: 'https://idp.fixture.test/application/o/token/', userinfo_endpoint: 'https://idp.fixture.test/application/o/userinfo/' };
const jsonResponse = data => new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
test('OIDC discovery keeps the configured issuer exact, removes the path slash once, and honors explicit overrides', async () => {
const requests = [];
const endpoints = await discoverOidcEndpoints({ OIDC_ISSUER_URL: issuer }, { fetchImpl: async (url, options) => {
requests.push({ url, options }); return jsonResponse(metadata);
} });
assert.equal(requests[0].url, issuer + '.well-known/openid-configuration');
assert.equal(requests[0].options.redirect, 'manual');
assert.ok(requests[0].options.signal);
assert.deepEqual(endpoints, { issuer, authorizationURL: metadata.authorization_endpoint, tokenURL: metadata.token_endpoint, userInfoURL: metadata.userinfo_endpoint });
const partial = await discoverOidcEndpoints({ OIDC_ISSUER_URL: issuer, OIDC_AUTH_URL: 'https://custom.fixture.test/auth' }, { fetchImpl: async () => jsonResponse(metadata) });
assert.equal(partial.authorizationURL, 'https://custom.fixture.test/auth'); assert.equal(partial.tokenURL, metadata.token_endpoint);
const manual = await discoverOidcEndpoints({ OIDC_ISSUER_URL: issuer, OIDC_AUTH_URL: metadata.authorization_endpoint,
OIDC_TOKEN_URL: metadata.token_endpoint, OIDC_USERINFO_URL: metadata.userinfo_endpoint }, { fetchImpl: () => { throw Error('Discovery must be skipped'); } });
assert.deepEqual(manual, endpoints);
const withoutUserinfo = await discoverOidcEndpoints({ OIDC_ISSUER_URL: issuer }, { fetchImpl: async () => jsonResponse({ ...metadata, userinfo_endpoint: undefined }) });
assert.equal(withoutUserinfo.userInfoURL, undefined, 'userinfo endpoint is optional in OIDC discovery');
});
test('OIDC rejects mismatched issuers, malformed metadata, unsafe endpoints, redirects, and oversized bodies', async () => {
for (const data of [{ ...metadata, issuer: issuer.slice(0, -1) }, { ...metadata, issuer: 'https://other.fixture.test' }, [], null,
{ ...metadata, token_endpoint: undefined }, { ...metadata, token_endpoint: 'file:///tmp/token' },
{ ...metadata, token_endpoint: 'http://idp.fixture.test/token' }, { ...metadata, authorization_endpoint: 'javascript:alert(1)' },
{ ...metadata, userinfo_endpoint: 'https://user:secret@idp.fixture.test/userinfo' }]) {
await assert.rejects(discoverOidcEndpoints({ OIDC_ISSUER_URL: issuer }, { fetchImpl: async () => jsonResponse(data) }));
}
for (const value of ['file:///tmp/issuer', 'https://user:secret@idp.fixture.test', issuer + '?query=1', issuer + '#fragment']) {
await assert.rejects(discoverOidcEndpoints({ OIDC_ISSUER_URL: value }));
}
for (const status of [302, 404, 500]) {
await assert.rejects(discoverOidcEndpoints({ OIDC_ISSUER_URL: issuer }, { fetchImpl: async () => new Response('', { status }) }), /returned HTTP/);
}
await assert.rejects(discoverOidcEndpoints({ OIDC_ISSUER_URL: issuer }, { fetchImpl: async () => new Response('not json') }), /not valid JSON/);
await assert.rejects(discoverOidcEndpoints({ OIDC_ISSUER_URL: issuer }, { fetchImpl: async () => new Response('x'.repeat(128 * 1024 + 1)) }), /too large/);
await assert.rejects(discoverOidcEndpoints({ OIDC_ISSUER_URL: issuer }, { fetchImpl: async () => new Response('x', { headers: { 'Content-Length': '999999' } }) }), /too large/);
});
async function listen(t, handler) {
const server = http.createServer(handler);
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise(resolve => { server.close(resolve); server.closeAllConnections(); }));
return `http://127.0.0.1:${server.address().port}`;
}
test('OIDC discovery timeout covers both response headers and a stalled response body', async t => {
const base = await listen(t, (req, res) => {
if (req.url.startsWith('/body/')) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.write('{'); }
});
for (const part of ['headers', 'body']) {
const began = Date.now();
await assert.rejects(discoverOidcEndpoints({ OIDC_ISSUER_URL: base + '/' + part }, { timeoutMs: 50 }), /timed out/);
assert.ok(Date.now() - began < 2000);
}
});
async function startApp(t, env = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nodecast-oidc-'));
fs.writeFileSync(path.join(dir, 'db.json'), JSON.stringify({ users: [], sources: [], hiddenItems: [], favorites: [], settings: {}, nextId: 1 }));
const child = spawn(process.execPath, ['server/index.js'], { cwd: path.join(__dirname, '..'),
env: { ...process.env, PORT: '0', NODECAST_DATA_DIR: dir, NODECAST_DISABLE_BACKGROUND_JOBS: 'true',
JWT_SECRET: '', SESSION_SECRET: '', AUTH_COOKIE_SECURE: 'false',
OIDC_ISSUER_URL: '', OIDC_CLIENT_ID: '', OIDC_CLIENT_SECRET: '', OIDC_AUTH_URL: '', OIDC_TOKEN_URL: '', OIDC_USERINFO_URL: '', OIDC_CALLBACK_URL: '', ...env },
stdio: ['ignore', 'pipe', 'pipe'] });
t.after(async () => {
if (child.exitCode === null) { const closed = new Promise(resolve => child.once('close', resolve)); child.kill(); await closed; }
fs.rmSync(dir, { recursive: true, force: true });
});
let output = '';
const base = await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(Error('App startup timed out')), 15000);
child.stdout.on('data', data => {
output += data;
const match = output.match(/server running on http:\/\/localhost:(\d+)/);
if (match) { clearTimeout(timer); resolve('http://127.0.0.1:' + match[1]); }
});
child.stderr.on('data', data => { output += data; });
child.once('error', error => { clearTimeout(timer); reject(error); });
child.once('exit', code => { clearTimeout(timer); reject(Error('App exited ' + code + ': ' + output)); });
});
return { base };
}
for (const provider of ['authentik', 'keycloak']) {
test(`real app waits for ${provider} discovery before SSO and uses the published authorization endpoint`, async t => {
let release, requested, docRequests = 0;
const gate = new Promise(resolve => { release = resolve; });
const ready = new Promise(resolve => { requested = resolve; });
let providerBase;
const prefix = provider === 'authentik' ? '/application/o/nodecast/' : '/realms/nodecast';
const authPath = provider === 'authentik' ? '/application/o/authorize/' : '/realms/nodecast/protocol/openid-connect/auth';
providerBase = await listen(t, async (req, res) => {
docRequests++;
assert.equal(req.url, prefix.replace(/\/$/, '') + '/.well-known/openid-configuration');
requested(); await gate;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ issuer: providerBase + prefix, authorization_endpoint: providerBase + authPath,
token_endpoint: providerBase + '/token', userinfo_endpoint: providerBase + '/userinfo' }));
});
const { base } = await startApp(t, { OIDC_ISSUER_URL: providerBase + prefix, OIDC_CLIENT_ID: 'fixture-client', OIDC_CLIENT_SECRET: 'fixture-secret' });
await ready;
assert.equal((await fetch(base + '/api/health')).status, 503, 'readiness waits for discovery');
const login = fetch(base + '/api/auth/oidc/login', { redirect: 'manual' });
release();
const response = await login;
assert.equal(response.status, 302);
const redirect = new URL(response.headers.get('location'));
assert.equal(redirect.origin, providerBase); assert.equal(redirect.pathname, authPath);
assert.equal(redirect.searchParams.get('client_id'), 'fixture-client');
assert.ok(redirect.searchParams.get('state'));
let healthStatus;
for (let attempt = 0; attempt < 50; attempt++) {
healthStatus = (await fetch(base + '/api/health')).status;
if (healthStatus === 200) break;
await new Promise(resolve => setTimeout(resolve, 20));
}
assert.equal(healthStatus, 200, 'ready after discovery and plugin initialization');
const callback = await fetch(base + '/api/auth/oidc/callback?error=access_denied', { redirect: 'manual' });
assert.equal(callback.status, 302);
assert.ok(callback.headers.get('location').startsWith('/login.html?error='));
assert.equal(docRequests, 1);
});
}
test('failed or unconfigured SSO returns a clear 503 while local setup and login remain usable', async t => {
const provider = await listen(t, (req, res) => {
res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ ...metadata, issuer: 'https://wrong.fixture.test' }));
});
for (const env of [{}, { OIDC_ISSUER_URL: provider + '/issuer', OIDC_CLIENT_ID: 'fixture-client', OIDC_CLIENT_SECRET: 'fixture-secret' }]) {
const { base } = await startApp(t, env);
for (const route of ['/api/auth/oidc/login', '/api/auth/oidc/callback']) {
const response = await fetch(base + route, { redirect: 'manual' });
assert.equal(response.status, 503); assert.match((await response.json()).error, /unavailable/);
}
const setup = await fetch(base + '/api/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'Fixture', password: 'fixture-password' }) });
assert.equal(setup.status, 201);
const login = await fetch(base + '/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'Fixture', password: 'fixture-password' }) });
assert.equal(login.status, 200);
assert.equal((await fetch(base + '/api/health')).status, 200);
}
});