Fix safe login submission, mobile fullscreen, and OIDC discovery
This commit is contained in:
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
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 };
|
||||
Reference in New Issue
Block a user