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

@ -1,3 +1,4 @@
const { discoverOidcEndpoints } = require('./services/oidcDiscovery');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const passport = require('passport');
@ -142,21 +143,19 @@ function configureSessionSerialization(getUserById) {
/**
* 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) {
console.warn('OIDC configuration missing - SSO disabled');
return;
return false;
}
const { Strategy: OpenIDConnectStrategy } = require('passport-openidconnect');
const endpoints = await discoverOidcEndpoints(process.env);
passport.use(new OpenIDConnectStrategy({
issuer: process.env.OIDC_ISSUER_URL || 'https://mock-issuer.com', // Dummy default for mock
authorizationURL: process.env.OIDC_AUTH_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/auth`,
tokenURL: process.env.OIDC_TOKEN_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/token`,
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',
...endpoints,
clientID: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
callbackURL: process.env.OIDC_CALLBACK_URL || '/api/auth/oidc/callback',
scope: ['openid', 'profile', 'email']
},
@ -233,6 +232,7 @@ function configureOidcStrategy(findUserByOidcId, findUserByEmail, createUser) {
return done(err);
}
}));
return true;
}
/**

View File

@ -181,7 +181,8 @@ process.on('SIGTERM', async () => {
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);

View File

@ -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

View 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 };