From ce122eb34ba66f8163044cc19cd86bb0eb9ae787 Mon Sep 17 00:00:00 2001 From: Technomancer702 Date: Tue, 13 Oct 2026 18:31:48 -0700 Subject: [PATCH] Fix safe login submission, mobile fullscreen, and OIDC discovery --- README.md | 4 + package-lock.json | 4 +- package.json | 2 +- public/index.html | 5 +- public/js/components/PlayerFullscreen.js | 31 +++++ public/js/components/VideoPlayer.js | 31 +---- public/js/pages/WatchPage.js | 20 +-- public/login.html | 11 +- server/auth.js | 16 +-- server/index.js | 5 +- server/routes/auth.js | 19 ++- server/services/oidcDiscovery.js | 74 ++++++++++ test/caption-tracks.test.js | 2 +- test/hls-ad-break.test.js | 2 +- test/login-fullscreen.test.js | 170 +++++++++++++++++++++++ test/oidc-discovery.test.js | 155 +++++++++++++++++++++ 16 files changed, 479 insertions(+), 72 deletions(-) create mode 100644 public/js/components/PlayerFullscreen.js create mode 100644 server/services/oidcDiscovery.js create mode 100644 test/login-fullscreen.test.js create mode 100644 test/oidc-discovery.test.js diff --git a/README.md b/README.md index af5942b..2606f16 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,10 @@ OIDC_CLIENT_SECRET=your_client_secret OIDC_CALLBACK_URL=http://localhost:3000/api/auth/oidc/callback # Adjust for your domain ``` +The app loads the provider's `/.well-known/openid-configuration` document at startup, using its published authorization, token and optional userinfo endpoints. Copy the provider's issuer URL exactly, including its trailing slash if present; the returned issuer must match. See the [OpenID Connect discovery specification](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig). + +`OIDC_AUTH_URL`, `OIDC_TOKEN_URL` and `OIDC_USERINFO_URL` can override individual endpoints. Supplying all three skips discovery. HTTPS issuers require HTTPS endpoints; HTTP is retained for local development configurations. Discovery has a five-second timeout and a 128 KiB response limit. If it fails, SSO returns an unavailable message and local login remains usable; correct the provider configuration and restart the app. + **Note:** New users signing in via SSO are automatically assigned the **Viewer** role. You must manually promote them to Admin if desired. ### Usage diff --git a/package-lock.json b/package-lock.json index b8d07f4..1f31400 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nodecast-tv", - "version": "2.2.3", + "version": "2.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nodecast-tv", - "version": "2.2.3", + "version": "2.2.4", "license": "GPL-3.0-only", "dependencies": { "@ffprobe-installer/ffprobe": "^2.1.2", diff --git a/package.json b/package.json index 9e1bd91..f287db4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nodecast-tv", - "version": "2.2.3", + "version": "2.2.4", "description": "Modern IPTV Player", "license": "GPL-3.0-only", "main": "server/index.js", diff --git a/public/index.html b/public/index.html index 4cd63c4..ab982c1 100644 --- a/public/index.html +++ b/public/index.html @@ -1107,7 +1107,8 @@ - + + @@ -1117,7 +1118,7 @@ - + diff --git a/public/js/components/PlayerFullscreen.js b/public/js/components/PlayerFullscreen.js new file mode 100644 index 0000000..fb52592 --- /dev/null +++ b/public/js/components/PlayerFullscreen.js @@ -0,0 +1,31 @@ +/** Invoke fullscreen synchronously within the click/keyboard gesture, then handle its promise. */ +const PlayerFullscreen = { + async toggle(video, container, doc = document) { + try { + if (doc.fullscreenElement && doc.exitFullscreen) { + await doc.exitFullscreen(); + } else if (doc.webkitFullscreenElement && doc.webkitExitFullscreen) { + await doc.webkitExitFullscreen(); + } else if (video?.webkitDisplayingFullscreen && video.webkitExitFullscreen) { + await video.webkitExitFullscreen(); + } else { + const target = container || video; + // Some iPhones expose element methods even though element fullscreen is disabled. + const standard = doc.fullscreenEnabled === true || + (doc.fullscreenEnabled === undefined && !video?.webkitEnterFullscreen); + const prefixed = doc.webkitFullscreenEnabled === true || + (doc.webkitFullscreenEnabled === undefined && doc.fullscreenEnabled !== false && !video?.webkitEnterFullscreen); + if (standard && target?.requestFullscreen) await target.requestFullscreen(); + else if (prefixed && target?.webkitRequestFullscreen) await target.webkitRequestFullscreen(); + else if (video?.webkitEnterFullscreen) await video.webkitEnterFullscreen(); + else return false; + } + return true; + } catch (error) { + console.error('Fullscreen failed:', error.message); + return false; + } + } +}; +if (typeof window !== 'undefined') window.PlayerFullscreen = PlayerFullscreen; +if (typeof module !== 'undefined') module.exports = PlayerFullscreen; diff --git a/public/js/components/VideoPlayer.js b/public/js/components/VideoPlayer.js index e587714..05f73b0 100644 --- a/public/js/components/VideoPlayer.js +++ b/public/js/components/VideoPlayer.js @@ -422,25 +422,7 @@ class VideoPlayer { * Toggle fullscreen mode (cross-browser including Safari) */ toggleFullscreen() { - const isFullscreen = document.fullscreenElement || document.webkitFullscreenElement; - - if (isFullscreen) { - if (document.exitFullscreen) { - document.exitFullscreen(); - } else if (document.webkitExitFullscreen) { - document.webkitExitFullscreen(); - } - } else { - const element = this.container; - if (element.requestFullscreen) { - element.requestFullscreen().catch(err => console.error('Fullscreen error:', err)); - } else if (element.webkitRequestFullscreen) { - element.webkitRequestFullscreen(); - } else if (this.video.webkitEnterFullscreen) { - // iOS Safari: use native video fullscreen - this.video.webkitEnterFullscreen(); - } - } + return PlayerFullscreen.toggle(this.video, this.container, document); } /** @@ -1306,17 +1288,6 @@ class VideoPlayer { window.app.channelList.selectChannel({ channelId: channels[nextIdx].id }); } - /** - * Toggle fullscreen - */ - toggleFullscreen() { - if (document.fullscreenElement) { - document.exitFullscreen(); - } else if (this.container) { - this.container.requestFullscreen(); - } - } - async zapBeforeStreaming(channel, streamUrl, signal) { if (!channel?.zapBeforeStreaming) return; if (!channel?.streamId || !streamUrl) return; diff --git a/public/js/pages/WatchPage.js b/public/js/pages/WatchPage.js index bc027e6..f6a1af5 100644 --- a/public/js/pages/WatchPage.js +++ b/public/js/pages/WatchPage.js @@ -756,25 +756,7 @@ class WatchPage { } toggleFullscreen() { - const container = document.querySelector('.watch-video-section'); - const isFullscreen = document.fullscreenElement || document.webkitFullscreenElement; - - if (isFullscreen) { - if (document.exitFullscreen) { - document.exitFullscreen(); - } else if (document.webkitExitFullscreen) { - document.webkitExitFullscreen(); - } - } else { - if (container?.requestFullscreen) { - container.requestFullscreen(); - } else if (container?.webkitRequestFullscreen) { - container.webkitRequestFullscreen(); - } else if (this.video?.webkitEnterFullscreen) { - // iOS Safari: use native video fullscreen - this.video.webkitEnterFullscreen(); - } - } + return PlayerFullscreen.toggle(this.video, document.querySelector('.watch-video-section'), document); } async togglePictureInPicture() { diff --git a/public/login.html b/public/login.html index 644f7e9..203ac5f 100644 --- a/public/login.html +++ b/public/login.html @@ -180,7 +180,7 @@ Welcome! Please create your admin account to get started. -
+
- +
+
{ app.get('/api/health', require('./services/health')); app.get('/api/version', (req, res) => res.json({ version: require('../package.json').version })); app.use('/api', auth.requireSameOrigin); -app.use('/api/auth', require('./routes/auth')); +const authRoutes = require('./routes/auth'); +app.use('/api/auth', authRoutes); app.use('/api', auth.requireAuth); app.use('/api', (req, res, next) => { try { @@ -224,6 +225,8 @@ app.use((err, req, res, next) => { const server = app.listen(PORT, async () => { console.log(`NodeCast TV server running on http://localhost:${server.address().port}`); + // Finish SSO discovery before announcing readiness. Local login remains available on failure. + await authRoutes.oidcReady; // Load plugins await loadPlugins().catch(err => { console.error('Plugin initialization failed:', err); diff --git a/server/routes/auth.js b/server/routes/auth.js index 87cc571..00884b7 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -21,24 +21,33 @@ auth.configureSessionSerialization( async (id) => await db.users.getById(id) ); -// Configure OIDC Strategy -auth.configureOidcStrategy( +// Configure OIDC once; SSO routes wait until discovery and registration finish. +let oidcAvailable = false; +const oidcReady = auth.configureOidcStrategy( async (oidcId) => await db.users.getByOidcId(oidcId), async (email) => await db.users.getByEmail(email), async (userData) => await db.users.create(userData) -); +).then(available => { oidcAvailable = available; }).catch(error => { + console.error('[OIDC] SSO unavailable:', error.message); +}); +router.oidcReady = oidcReady; +async function requireOidc(req, res, next) { + await oidcReady; + if (!oidcAvailable) return res.status(503).json({ error: 'Single sign-on is unavailable. Contact your administrator.' }); + next(); +} /** * Start OIDC Login * GET /api/auth/oidc/login */ -router.get('/oidc/login', auth.passport.authenticate('openidconnect')); +router.get('/oidc/login', requireOidc, auth.passport.authenticate('openidconnect')); /** * OIDC Callback * GET /api/auth/oidc/callback */ -router.get('/oidc/callback', +router.get('/oidc/callback', requireOidc, auth.passport.authenticate('openidconnect', { session: false, failureRedirect: '/login.html?error=SSO+Failed' }), (req, res) => { // Successful authentication diff --git a/server/services/oidcDiscovery.js b/server/services/oidcDiscovery.js new file mode 100644 index 0000000..9dff859 --- /dev/null +++ b/server/services/oidcDiscovery.js @@ -0,0 +1,74 @@ +const MAX_DOCUMENT_BYTES = 128 * 1024; + +function validateUrl(value, name, allowHttp) { + if (typeof value !== 'string' || !value || /[\x00-\x20\x7f]/.test(value)) throw new Error('Invalid OIDC ' + name); + let parsed; + try { parsed = new URL(value); } catch { throw new Error('Invalid OIDC ' + name); } + if (!(parsed.protocol === 'https:' || (allowHttp && parsed.protocol === 'http:')) || + parsed.username || parsed.password || parsed.hash) throw new Error('Invalid OIDC ' + name); + return parsed; +} + +async function readDocument(response) { + const length = Number(response.headers.get('content-length')); + if (length > MAX_DOCUMENT_BYTES) { + await response.body?.cancel(); + throw new Error('OIDC discovery document is too large'); + } + if (!response.body) throw new Error('OIDC discovery document is empty'); + const reader = response.body.getReader(); + const chunks = []; + let size = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + size += value.length; + if (size > MAX_DOCUMENT_BYTES) { + await reader.cancel(); + throw new Error('OIDC discovery document is too large'); + } + chunks.push(Buffer.from(value)); + } + } finally { reader.releaseLock(); } + try { return JSON.parse(Buffer.concat(chunks, size).toString('utf8')); } + catch { throw new Error('OIDC discovery document is not valid JSON'); } +} + +async function discoverOidcEndpoints(env, { fetchImpl = fetch, timeoutMs = 5000 } = {}) { + const issuer = env.OIDC_ISSUER_URL; + const issuerUrl = validateUrl(issuer, 'issuer URL', true); + if (issuerUrl.search) throw new Error('OIDC issuer URL must not contain a query'); + const allowHttp = issuerUrl.protocol === 'http:'; + const overrides = [env.OIDC_AUTH_URL, env.OIDC_TOKEN_URL, env.OIDC_USERINFO_URL]; + let document = {}; + if (!overrides.every(Boolean)) { + let response; + try { + response = await fetchImpl(issuer.replace(/\/+$/, '') + '/.well-known/openid-configuration', { + headers: { Accept: 'application/json' }, redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) + }); + } catch { throw new Error('OIDC discovery request failed or timed out'); } + if (response.status !== 200) { + await response.body?.cancel(); + throw new Error('OIDC discovery returned HTTP ' + response.status); + } + try { document = await readDocument(response); } + catch (error) { + if (error.name === 'AbortError' || error.name === 'TimeoutError') throw new Error('OIDC discovery request timed out'); + throw error; + } + if (!document || typeof document !== 'object' || Array.isArray(document) || document.issuer !== issuer) { + throw new Error('OIDC discovery issuer does not match OIDC_ISSUER_URL'); + } + } + const authorizationURL = env.OIDC_AUTH_URL || document.authorization_endpoint; + const tokenURL = env.OIDC_TOKEN_URL || document.token_endpoint; + const userInfoURL = env.OIDC_USERINFO_URL || document.userinfo_endpoint; + validateUrl(authorizationURL, 'authorization endpoint', allowHttp); + validateUrl(tokenURL, 'token endpoint', allowHttp); + if (userInfoURL !== undefined) validateUrl(userInfoURL, 'userinfo endpoint', allowHttp); + return { issuer, authorizationURL, tokenURL, userInfoURL }; +} + +module.exports = { discoverOidcEndpoints }; diff --git a/test/caption-tracks.test.js b/test/caption-tracks.test.js index 5f66c41..a0ae486 100644 --- a/test/caption-tracks.test.js +++ b/test/caption-tracks.test.js @@ -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'; diff --git a/test/hls-ad-break.test.js b/test/hls-ad-break.test.js index b2876ca..f4a1bf5 100644 --- a/test/hls-ad-break.test.js +++ b/test/hls-ad-break.test.js @@ -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); }; diff --git a/test/login-fullscreen.test.js b/test/login-fullscreen.test.js new file mode 100644 index 0000000..4116848 --- /dev/null +++ b/test/login-fullscreen.test.js @@ -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('

Main app

'); 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(`
${scripts.map(src => ``).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(); + } + }); diff --git a/test/oidc-discovery.test.js b/test/oidc-discovery.test.js new file mode 100644 index 0000000..5967023 --- /dev/null +++ b/test/oidc-discovery.test.js @@ -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); + } +});