feat: implement OIDC SSO with JIT provisioning

This commit is contained in:
Trevor Mears
2026-01-17 22:52:06 -08:00
parent 82b93602c1
commit 5752f529cb
8 changed files with 273 additions and 39 deletions
+38 -1
View File
@@ -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",
+2
View File
@@ -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"
},
+14
View File
@@ -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>
+37
View File
@@ -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);
+95
View File
@@ -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
View File
@@ -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()
};
+8
View File
@@ -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')));
+28
View File
@@ -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