feat: implement OIDC SSO with JIT provisioning
This commit is contained in:
Generated
+38
-1
@@ -6,18 +6,20 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nodecast-tv",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@ffprobe-installer/ffprobe": "^2.1.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.18.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"passport-openidconnect": "^0.1.2",
|
||||
"sax": "^1.4.3",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
@@ -525,6 +527,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.2.3",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -1196,6 +1210,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/oauth": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz",
|
||||
"integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
@@ -1292,6 +1312,23 @@
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-openidconnect": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/passport-openidconnect/-/passport-openidconnect-0.1.2.tgz",
|
||||
"integrity": "sha512-JX3rTyW+KFZ/E9OF/IpXJPbyLO9vGzcmXB5FgSP2jfL3LGKJPdV7zUE8rWeKeeI/iueQggOeFa3onrCmhxXZTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"oauth": "0.10.x",
|
||||
"passport-strategy": "1.x.x"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jaredhanson"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-strategy": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
||||
|
||||
@@ -12,12 +12,14 @@
|
||||
"@ffprobe-installer/ffprobe": "^2.1.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.18.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"passport-openidconnect": "^0.1.2",
|
||||
"sax": "^1.4.3",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
|
||||
@@ -990,6 +990,20 @@
|
||||
<script src="/js/pages/Settings.js?v=2"></script>
|
||||
<script src="/js/pages/WatchPage.js?v=1"></script>
|
||||
<script src="/js/app.js?v=4"></script>
|
||||
<script>
|
||||
// Check for SSO token in URL
|
||||
(function () {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const token = urlParams.get('token');
|
||||
if (token) {
|
||||
console.log('SSO Login successful, saving token...');
|
||||
localStorage.setItem('authToken', token);
|
||||
// Clean URL without reloading
|
||||
const newUrl = window.location.pathname;
|
||||
window.history.replaceState({}, document.title, newUrl);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -195,10 +195,44 @@
|
||||
|
||||
<button type="submit" class="btn-login" id="submit-btn">Sign In</button>
|
||||
</form>
|
||||
|
||||
<div class="sso-divider" style="text-align: center; margin: 20px 0; position: relative;">
|
||||
<span
|
||||
style="background: var(--color-bg-secondary); padding: 0 10px; color: var(--color-text-secondary); font-size: 14px; position: relative; z-index: 1;">OR</span>
|
||||
<div
|
||||
style="position: absolute; top: 50%; left: 0; right: 0; height: 1px; background: var(--color-border); z-index: 0;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="btn-sso-login" class="btn-login"
|
||||
style="background: transparent; border: 1px solid var(--color-border); color: var(--color-text-primary); display: flex; align-items: center; justify-content: center; gap: 10px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
|
||||
style="width: 20px; height: 20px;">
|
||||
<path
|
||||
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z" />
|
||||
</svg>
|
||||
Sign in with SSO
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Start SSO Login
|
||||
document.getElementById('btn-sso-login').addEventListener('click', () => {
|
||||
window.location.href = '/api/auth/oidc/login';
|
||||
});
|
||||
|
||||
// Check for URL Error params
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const error = urlParams.get('error');
|
||||
if (error) {
|
||||
const errorMessage = document.getElementById('error-message');
|
||||
errorMessage.textContent = error;
|
||||
errorMessage.classList.add('show');
|
||||
// Clean URL
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
}
|
||||
|
||||
// Check if setup is required
|
||||
let isSetupMode = false;
|
||||
|
||||
@@ -213,6 +247,9 @@
|
||||
document.getElementById('submit-btn').textContent = 'Create Account';
|
||||
document.getElementById('setup-message').classList.add('show');
|
||||
document.getElementById('password').placeholder = 'Minimum 6 characters';
|
||||
// Hide SSO in setup mode
|
||||
document.querySelector('.sso-divider').style.display = 'none';
|
||||
document.getElementById('btn-sso-login').style.display = 'none';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking setup status:', err);
|
||||
|
||||
@@ -110,6 +110,100 @@ function configureJwtStrategy(getUserById) {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Passport OpenID Connect Strategy
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
const { Strategy: OpenIDConnectStrategy } = require('passport-openidconnect');
|
||||
|
||||
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',
|
||||
callbackURL: process.env.OIDC_CALLBACK_URL || '/api/auth/oidc/callback',
|
||||
scope: ['openid', 'profile', 'email']
|
||||
},
|
||||
async (...args) => {
|
||||
// The done callback is always the last argument
|
||||
const done = args[args.length - 1];
|
||||
|
||||
// Map known arguments
|
||||
// Standard: issuer, sub, profile, accessToken, refreshToken, done
|
||||
// Some versions: issuer, sub, profile, accessToken, refreshToken, params, done
|
||||
|
||||
let issuer, sub, profile;
|
||||
|
||||
if (args.length === 3) {
|
||||
// Scenario: (issuer, profile, done)
|
||||
const arg0 = args[0];
|
||||
const arg1 = args[1];
|
||||
|
||||
if (typeof arg1 === 'object' && arg1.id) {
|
||||
issuer = arg0;
|
||||
profile = arg1;
|
||||
sub = profile.id;
|
||||
} else if (typeof arg0 === 'string' && typeof arg1 === 'string') {
|
||||
issuer = arg0;
|
||||
sub = arg1;
|
||||
profile = { id: sub, displayName: 'Unknown' };
|
||||
}
|
||||
} else if (args.length >= 4) {
|
||||
// Assume standard: iss, sub, profile...
|
||||
issuer = args[0];
|
||||
sub = args[1];
|
||||
profile = args[2];
|
||||
}
|
||||
|
||||
if (!sub && profile && profile.id) sub = profile.id;
|
||||
|
||||
if (!sub) {
|
||||
return done(new Error('Could not identify OIDC Subject (sub) from arguments'));
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Try to find by OIDC ID (sub)
|
||||
let user = await findUserByOidcId(sub);
|
||||
|
||||
// 2. If not found, try to match by email
|
||||
if (!user && profile.emails && profile.emails.length > 0) {
|
||||
const email = profile.emails[0].value;
|
||||
user = await findUserByEmail(email);
|
||||
|
||||
// If found by email but no OIDC ID, link them
|
||||
if (user && !user.oidcId) {
|
||||
// We don't have a direct update method for specific fields without full user object in this context
|
||||
// Ideally we'd update the user here. For now, we'll just log in.
|
||||
// Future: Update user with oidcId
|
||||
}
|
||||
}
|
||||
|
||||
// 3. If still not found, create new user (JIT Provisioning)
|
||||
if (!user) {
|
||||
const username = profile.username || profile.displayName || (profile.emails ? profile.emails[0].value.split('@')[0] : `user_${sub.substring(0, 8)}`);
|
||||
|
||||
user = await createUser({
|
||||
username: username,
|
||||
role: 'viewer', // Default role for SSO users
|
||||
oidcId: sub,
|
||||
email: profile.emails ? profile.emails[0].value : null
|
||||
});
|
||||
}
|
||||
|
||||
return done(null, user);
|
||||
} catch (err) {
|
||||
return done(err);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: Require authentication using Passport JWT
|
||||
*/
|
||||
@@ -145,6 +239,7 @@ module.exports = {
|
||||
verifyToken,
|
||||
configureLocalStrategy,
|
||||
configureJwtStrategy,
|
||||
configureOidcStrategy,
|
||||
requireAuth,
|
||||
requireAdmin,
|
||||
requireRole
|
||||
|
||||
+14
-1
@@ -365,6 +365,16 @@ const users = {
|
||||
return db.users?.find(u => u.username === username);
|
||||
},
|
||||
|
||||
async getByOidcId(oidcId) {
|
||||
const db = await loadDb();
|
||||
return db.users?.find(u => u.oidcId === oidcId);
|
||||
},
|
||||
|
||||
async getByEmail(email) {
|
||||
const db = await loadDb();
|
||||
return db.users?.find(u => u.email === email);
|
||||
},
|
||||
|
||||
async create(userData) {
|
||||
const db = await loadDb();
|
||||
if (!db.users) {
|
||||
@@ -379,8 +389,11 @@ const users = {
|
||||
const newUser = {
|
||||
id: db.nextId++,
|
||||
username: userData.username,
|
||||
passwordHash: userData.passwordHash,
|
||||
// For OIDC users, passwordHash is optional
|
||||
passwordHash: userData.passwordHash || null,
|
||||
role: userData.role || 'viewer',
|
||||
oidcId: userData.oidcId || null,
|
||||
email: userData.email || null,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const express = require('express');
|
||||
require('dotenv').config();
|
||||
const path = require('path');
|
||||
const passport = require('passport');
|
||||
const syncService = require('./services/syncService');
|
||||
@@ -17,7 +18,14 @@ app.set('trust proxy', true);
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
|
||||
// Initialize Passport
|
||||
const session = require('express-session');
|
||||
app.use(session({
|
||||
secret: process.env.JWT_SECRET || 'keyboard cat',
|
||||
resave: false,
|
||||
saveUninitialized: true
|
||||
}));
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
|
||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||
|
||||
|
||||
@@ -13,6 +13,34 @@ auth.configureJwtStrategy(
|
||||
async (id) => await db.users.getById(id)
|
||||
);
|
||||
|
||||
// Configure OIDC Strategy
|
||||
auth.configureOidcStrategy(
|
||||
async (oidcId) => await db.users.getByOidcId(oidcId),
|
||||
async (email) => await db.users.getByEmail(email),
|
||||
async (userData) => await db.users.create(userData)
|
||||
);
|
||||
|
||||
/**
|
||||
* Start OIDC Login
|
||||
* GET /api/auth/oidc/login
|
||||
*/
|
||||
router.get('/oidc/login', auth.passport.authenticate('openidconnect'));
|
||||
|
||||
/**
|
||||
* OIDC Callback
|
||||
* GET /api/auth/oidc/callback
|
||||
*/
|
||||
router.get('/oidc/callback',
|
||||
auth.passport.authenticate('openidconnect', { session: false, failureRedirect: '/login.html?error=SSO+Failed' }),
|
||||
(req, res) => {
|
||||
// Successful authentication
|
||||
const token = auth.generateToken(req.user);
|
||||
|
||||
// Redirect to hompage with token
|
||||
res.redirect(`/?token=${token}`);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Check if initial setup is required
|
||||
* GET /api/auth/setup-required
|
||||
|
||||
Reference in New Issue
Block a user