Merge pull request #167 from technomancer702/codex/priority-bug-fixes
Fix login credential URLs, mobile fullscreen, and OIDC provider discovery
This commit is contained in:
@ -133,6 +133,10 @@ OIDC_CLIENT_SECRET=your_client_secret
|
|||||||
OIDC_CALLBACK_URL=http://localhost:3000/api/auth/oidc/callback # Adjust for your domain
|
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.
|
**Note:** New users signing in via SSO are automatically assigned the **Viewer** role. You must manually promote them to Admin if desired.
|
||||||
|
|
||||||
### Usage
|
### Usage
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "nodecast-tv",
|
"name": "nodecast-tv",
|
||||||
"version": "2.2.3",
|
"version": "2.2.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nodecast-tv",
|
"name": "nodecast-tv",
|
||||||
"version": "2.2.3",
|
"version": "2.2.4",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ffprobe-installer/ffprobe": "^2.1.2",
|
"@ffprobe-installer/ffprobe": "^2.1.2",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nodecast-tv",
|
"name": "nodecast-tv",
|
||||||
"version": "2.2.3",
|
"version": "2.2.4",
|
||||||
"description": "Modern IPTV Player",
|
"description": "Modern IPTV Player",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
|
|||||||
@ -1107,7 +1107,8 @@
|
|||||||
<script src="/js/components/VodDuration.js?v=1"></script>
|
<script src="/js/components/VodDuration.js?v=1"></script>
|
||||||
<script src="/js/components/CaptionTracks.js?v=2"></script>
|
<script src="/js/components/CaptionTracks.js?v=2"></script>
|
||||||
<script src="/js/components/HlsAudioRecovery.js?v=1"></script>
|
<script src="/js/components/HlsAudioRecovery.js?v=1"></script>
|
||||||
<script src="/js/components/VideoPlayer.js?v=6"></script>
|
<script src="/js/components/PlayerFullscreen.js?v=1"></script>
|
||||||
|
<script src="/js/components/VideoPlayer.js?v=7"></script>
|
||||||
<script src="/js/components/ChannelList.js?v=4"></script>
|
<script src="/js/components/ChannelList.js?v=4"></script>
|
||||||
<script src="/js/components/SourceManager.js?v=3"></script>
|
<script src="/js/components/SourceManager.js?v=3"></script>
|
||||||
<script src="/js/components/EpgGuide.js?v=2"></script>
|
<script src="/js/components/EpgGuide.js?v=2"></script>
|
||||||
@ -1117,7 +1118,7 @@
|
|||||||
<script src="/js/pages/MoviesPage.js?v=4"></script>
|
<script src="/js/pages/MoviesPage.js?v=4"></script>
|
||||||
<script src="/js/pages/SeriesPage.js?v=1"></script>
|
<script src="/js/pages/SeriesPage.js?v=1"></script>
|
||||||
<script src="/js/pages/Settings.js?v=2"></script>
|
<script src="/js/pages/Settings.js?v=2"></script>
|
||||||
<script src="/js/pages/WatchPage.js?v=6"></script>
|
<script src="/js/pages/WatchPage.js?v=7"></script>
|
||||||
<script src="/js/app.js?v=5"></script>
|
<script src="/js/app.js?v=5"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
31
public/js/components/PlayerFullscreen.js
Normal file
31
public/js/components/PlayerFullscreen.js
Normal file
@ -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;
|
||||||
@ -422,25 +422,7 @@ class VideoPlayer {
|
|||||||
* Toggle fullscreen mode (cross-browser including Safari)
|
* Toggle fullscreen mode (cross-browser including Safari)
|
||||||
*/
|
*/
|
||||||
toggleFullscreen() {
|
toggleFullscreen() {
|
||||||
const isFullscreen = document.fullscreenElement || document.webkitFullscreenElement;
|
return PlayerFullscreen.toggle(this.video, this.container, document);
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1306,17 +1288,6 @@ class VideoPlayer {
|
|||||||
window.app.channelList.selectChannel({ channelId: channels[nextIdx].id });
|
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) {
|
async zapBeforeStreaming(channel, streamUrl, signal) {
|
||||||
if (!channel?.zapBeforeStreaming) return;
|
if (!channel?.zapBeforeStreaming) return;
|
||||||
if (!channel?.streamId || !streamUrl) return;
|
if (!channel?.streamId || !streamUrl) return;
|
||||||
|
|||||||
@ -756,25 +756,7 @@ class WatchPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggleFullscreen() {
|
toggleFullscreen() {
|
||||||
const container = document.querySelector('.watch-video-section');
|
return PlayerFullscreen.toggle(this.video, document.querySelector('.watch-video-section'), document);
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async togglePictureInPicture() {
|
async togglePictureInPicture() {
|
||||||
|
|||||||
@ -180,7 +180,7 @@
|
|||||||
Welcome! Please create your admin account to get started.
|
Welcome! Please create your admin account to get started.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="login-form">
|
<form id="login-form" method="post" action="/api/auth/login">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="username">Username</label>
|
<label for="username">Username</label>
|
||||||
<input type="text" id="username" name="username" required autocomplete="username"
|
<input type="text" id="username" name="username" required autocomplete="username"
|
||||||
@ -193,8 +193,9 @@
|
|||||||
minlength="6" placeholder="Enter your password">
|
minlength="6" placeholder="Enter your password">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn-login" id="submit-btn">Sign In</button>
|
<button type="submit" class="btn-login" id="submit-btn" disabled>Sign In</button>
|
||||||
</form>
|
</form>
|
||||||
|
<noscript><p class="hint">JavaScript is required to sign in.</p></noscript>
|
||||||
|
|
||||||
<div class="sso-divider" style="text-align: center; margin: 20px 0; position: relative;">
|
<div class="sso-divider" style="text-align: center; margin: 20px 0; position: relative;">
|
||||||
<span
|
<span
|
||||||
@ -248,7 +249,9 @@
|
|||||||
async function checkSetupRequired() {
|
async function checkSetupRequired() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/setup-required');
|
const response = await fetch('/api/auth/setup-required');
|
||||||
|
if (!response.ok) throw new Error('Sign-in status unavailable');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
if (typeof data.setupRequired !== 'boolean') throw new Error('Invalid sign-in status');
|
||||||
|
|
||||||
if (data.setupRequired) {
|
if (data.setupRequired) {
|
||||||
isSetupMode = true;
|
isSetupMode = true;
|
||||||
@ -260,8 +263,12 @@
|
|||||||
document.querySelector('.sso-divider').style.display = 'none';
|
document.querySelector('.sso-divider').style.display = 'none';
|
||||||
document.getElementById('btn-sso-login').style.display = 'none';
|
document.getElementById('btn-sso-login').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
document.getElementById('submit-btn').disabled = false;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error checking setup status:', err);
|
console.error('Error checking setup status:', err);
|
||||||
|
const message = document.getElementById('error-message');
|
||||||
|
message.textContent = 'Could not load sign-in status. Reload the page to try again.';
|
||||||
|
message.classList.add('show');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
const { discoverOidcEndpoints } = require('./services/oidcDiscovery');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const passport = require('passport');
|
const passport = require('passport');
|
||||||
@ -142,21 +143,19 @@ function configureSessionSerialization(getUserById) {
|
|||||||
/**
|
/**
|
||||||
* Configure Passport OpenID Connect Strategy
|
* Configure Passport OpenID Connect Strategy
|
||||||
*/
|
*/
|
||||||
function configureOidcStrategy(findUserByOidcId, findUserByEmail, createUser) {
|
async function configureOidcStrategy(findUserByOidcId, findUserByEmail, createUser) {
|
||||||
if (!process.env.OIDC_ISSUER_URL || !process.env.OIDC_CLIENT_ID || !process.env.OIDC_CLIENT_SECRET) {
|
if (!process.env.OIDC_ISSUER_URL || !process.env.OIDC_CLIENT_ID || !process.env.OIDC_CLIENT_SECRET) {
|
||||||
console.warn('OIDC configuration missing - SSO disabled');
|
console.warn('OIDC configuration missing - SSO disabled');
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { Strategy: OpenIDConnectStrategy } = require('passport-openidconnect');
|
const { Strategy: OpenIDConnectStrategy } = require('passport-openidconnect');
|
||||||
|
|
||||||
|
const endpoints = await discoverOidcEndpoints(process.env);
|
||||||
passport.use(new OpenIDConnectStrategy({
|
passport.use(new OpenIDConnectStrategy({
|
||||||
issuer: process.env.OIDC_ISSUER_URL || 'https://mock-issuer.com', // Dummy default for mock
|
...endpoints,
|
||||||
authorizationURL: process.env.OIDC_AUTH_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/auth`,
|
clientID: process.env.OIDC_CLIENT_ID,
|
||||||
tokenURL: process.env.OIDC_TOKEN_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/token`,
|
clientSecret: process.env.OIDC_CLIENT_SECRET,
|
||||||
userInfoURL: process.env.OIDC_USERINFO_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/userinfo`,
|
|
||||||
clientID: process.env.OIDC_CLIENT_ID || 'mock-client-id',
|
|
||||||
clientSecret: process.env.OIDC_CLIENT_SECRET || 'mock-secret',
|
|
||||||
callbackURL: process.env.OIDC_CALLBACK_URL || '/api/auth/oidc/callback',
|
callbackURL: process.env.OIDC_CALLBACK_URL || '/api/auth/oidc/callback',
|
||||||
scope: ['openid', 'profile', 'email']
|
scope: ['openid', 'profile', 'email']
|
||||||
},
|
},
|
||||||
@ -233,6 +232,7 @@ function configureOidcStrategy(findUserByOidcId, findUserByEmail, createUser) {
|
|||||||
return done(err);
|
return done(err);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -181,7 +181,8 @@ process.on('SIGTERM', async () => {
|
|||||||
app.get('/api/health', require('./services/health'));
|
app.get('/api/health', require('./services/health'));
|
||||||
app.get('/api/version', (req, res) => res.json({ version: require('../package.json').version }));
|
app.get('/api/version', (req, res) => res.json({ version: require('../package.json').version }));
|
||||||
app.use('/api', auth.requireSameOrigin);
|
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', auth.requireAuth);
|
||||||
app.use('/api', (req, res, next) => {
|
app.use('/api', (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@ -224,6 +225,8 @@ app.use((err, req, res, next) => {
|
|||||||
const server = app.listen(PORT, async () => {
|
const server = app.listen(PORT, async () => {
|
||||||
console.log(`NodeCast TV server running on http://localhost:${server.address().port}`);
|
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
|
// Load plugins
|
||||||
await loadPlugins().catch(err => {
|
await loadPlugins().catch(err => {
|
||||||
console.error('Plugin initialization failed:', err);
|
console.error('Plugin initialization failed:', err);
|
||||||
|
|||||||
@ -21,24 +21,33 @@ auth.configureSessionSerialization(
|
|||||||
async (id) => await db.users.getById(id)
|
async (id) => await db.users.getById(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Configure OIDC Strategy
|
// Configure OIDC once; SSO routes wait until discovery and registration finish.
|
||||||
auth.configureOidcStrategy(
|
let oidcAvailable = false;
|
||||||
|
const oidcReady = auth.configureOidcStrategy(
|
||||||
async (oidcId) => await db.users.getByOidcId(oidcId),
|
async (oidcId) => await db.users.getByOidcId(oidcId),
|
||||||
async (email) => await db.users.getByEmail(email),
|
async (email) => await db.users.getByEmail(email),
|
||||||
async (userData) => await db.users.create(userData)
|
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
|
* Start OIDC Login
|
||||||
* GET /api/auth/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
|
* OIDC Callback
|
||||||
* GET /api/auth/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' }),
|
auth.passport.authenticate('openidconnect', { session: false, failureRedirect: '/login.html?error=SSO+Failed' }),
|
||||||
(req, res) => {
|
(req, res) => {
|
||||||
// Successful authentication
|
// Successful authentication
|
||||||
|
|||||||
74
server/services/oidcDiscovery.js
Normal file
74
server/services/oidcDiscovery.js
Normal file
@ -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 };
|
||||||
@ -24,7 +24,7 @@ test('both subtitle menus select real HLS cues, discard stale tracks and preserv
|
|||||||
assert.equal(generated.status, 0, generated.stderr);
|
assert.equal(generated.status, 0, generated.stderr);
|
||||||
|
|
||||||
const root = path.join(__dirname, '../public');
|
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) =>
|
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-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';
|
'#EXT-X-STREAM-INF:BANDWIDTH=100000,CODECS="avc1.42c00a",SUBTITLES="subs"\n/video.m3u8\n';
|
||||||
|
|||||||
@ -37,7 +37,7 @@ test('Chromium plays program/ad/program audio with decoder resets at played boun
|
|||||||
manifest.push('#EXT-X-ENDLIST');
|
manifest.push('#EXT-X-ENDLIST');
|
||||||
const root = path.join(__dirname, '../public');
|
const root = path.join(__dirname, '../public');
|
||||||
const scripts = ['/js/components/CaptionTracks.js', '/js/components/HlsAudioRecovery.js',
|
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 server = http.createServer((req, res) => {
|
||||||
const url = new URL(req.url, 'http://fixture.test');
|
const url = new URL(req.url, 'http://fixture.test');
|
||||||
const send = (type, data) => { res.writeHead(200, { 'Content-Type': type }); res.end(data); };
|
const send = (type, data) => { res.writeHead(200, { 'Content-Type': type }); res.end(data); };
|
||||||
|
|||||||
170
test/login-fullscreen.test.js
Normal file
170
test/login-fullscreen.test.js
Normal 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
155
test/oidc-discovery.test.js
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user