feat: implement OIDC SSO with JIT provisioning
This commit is contained in:
+102
-7
@@ -63,17 +63,17 @@ function configureLocalStrategy(getUserByUsername, verifyUserPassword) {
|
||||
async (username, password, done) => {
|
||||
try {
|
||||
const user = await getUserByUsername(username);
|
||||
|
||||
|
||||
if (!user) {
|
||||
return done(null, false, { message: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
|
||||
const isValid = await verifyUserPassword(password, user.passwordHash);
|
||||
|
||||
|
||||
if (!isValid) {
|
||||
return done(null, false, { message: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
|
||||
return done(null, user);
|
||||
} catch (err) {
|
||||
return done(err);
|
||||
@@ -90,15 +90,15 @@ function configureJwtStrategy(getUserById) {
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
secretOrKey: JWT_SECRET
|
||||
};
|
||||
|
||||
|
||||
passport.use(new JwtStrategy(options, async (payload, done) => {
|
||||
try {
|
||||
const user = await getUserById(payload.id);
|
||||
|
||||
|
||||
if (!user) {
|
||||
return done(null, false);
|
||||
}
|
||||
|
||||
|
||||
return done(null, {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
@@ -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')));
|
||||
|
||||
|
||||
+56
-28
@@ -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
|
||||
@@ -34,22 +62,22 @@ router.get('/setup-required', async (req, res) => {
|
||||
router.post('/setup', async (req, res) => {
|
||||
try {
|
||||
const userCount = await db.users.count();
|
||||
|
||||
|
||||
// Check if setup already done
|
||||
if (userCount > 0) {
|
||||
return res.status(400).json({ error: 'Setup already completed' });
|
||||
}
|
||||
|
||||
|
||||
const { username, password } = req.body;
|
||||
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password required' });
|
||||
}
|
||||
|
||||
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
}
|
||||
|
||||
|
||||
// Create admin user
|
||||
const passwordHash = await auth.hashPassword(password);
|
||||
const adminUser = await db.users.create({
|
||||
@@ -57,10 +85,10 @@ router.post('/setup', async (req, res) => {
|
||||
passwordHash,
|
||||
role: 'admin'
|
||||
});
|
||||
|
||||
|
||||
// Generate token for immediate login
|
||||
const token = auth.generateToken(adminUser);
|
||||
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Admin user created successfully',
|
||||
token,
|
||||
@@ -82,14 +110,14 @@ router.post('/login', (req, res, next) => {
|
||||
console.error('Login error:', err);
|
||||
return res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: info?.message || 'Invalid credentials' });
|
||||
}
|
||||
|
||||
|
||||
// Generate JWT token
|
||||
const token = auth.generateToken(user);
|
||||
|
||||
|
||||
res.json({
|
||||
token,
|
||||
user: {
|
||||
@@ -118,11 +146,11 @@ router.post('/logout', (req, res) => {
|
||||
router.get('/me', auth.requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = await db.users.getById(req.user.id);
|
||||
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
|
||||
res.json({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
@@ -141,13 +169,13 @@ router.get('/me', auth.requireAuth, async (req, res) => {
|
||||
router.get('/users', auth.requireAuth, auth.requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const allUsers = await db.users.getAll();
|
||||
|
||||
|
||||
// Remove password hashes
|
||||
const users = allUsers.map(u => {
|
||||
const { passwordHash, ...userWithoutPassword } = u;
|
||||
return userWithoutPassword;
|
||||
});
|
||||
|
||||
|
||||
res.json(users);
|
||||
} catch (err) {
|
||||
console.error('Error fetching users:', err);
|
||||
@@ -162,26 +190,26 @@ router.get('/users', auth.requireAuth, auth.requireAdmin, async (req, res) => {
|
||||
router.post('/users', auth.requireAuth, auth.requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { username, password, role } = req.body;
|
||||
|
||||
|
||||
if (!username || !password || !role) {
|
||||
return res.status(400).json({ error: 'Username, password, and role are required' });
|
||||
}
|
||||
|
||||
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
}
|
||||
|
||||
|
||||
if (!['admin', 'viewer'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Role must be either "admin" or "viewer"' });
|
||||
}
|
||||
|
||||
|
||||
const passwordHash = await auth.hashPassword(password);
|
||||
const newUser = await db.users.create({
|
||||
username,
|
||||
passwordHash,
|
||||
role
|
||||
});
|
||||
|
||||
|
||||
res.status(201).json(newUser);
|
||||
} catch (err) {
|
||||
console.error('Error creating user:', err);
|
||||
@@ -197,25 +225,25 @@ router.put('/users/:id', auth.requireAuth, auth.requireAdmin, async (req, res) =
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { username, password, role } = req.body;
|
||||
|
||||
|
||||
const updates = {};
|
||||
|
||||
|
||||
if (username) {
|
||||
updates.username = username;
|
||||
}
|
||||
|
||||
|
||||
if (password) {
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
}
|
||||
updates.passwordHash = await auth.hashPassword(password);
|
||||
}
|
||||
|
||||
|
||||
if (role) {
|
||||
if (!['admin', 'viewer'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Role must be either "admin" or "viewer"' });
|
||||
}
|
||||
|
||||
|
||||
// Prevent removing admin role from the last admin
|
||||
const user = await db.users.getById(id);
|
||||
if (user && user.role === 'admin' && role !== 'admin') {
|
||||
@@ -225,10 +253,10 @@ router.put('/users/:id', auth.requireAuth, auth.requireAdmin, async (req, res) =
|
||||
return res.status(400).json({ error: 'Cannot remove admin role from the last admin user' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
updates.role = role;
|
||||
}
|
||||
|
||||
|
||||
const updatedUser = await db.users.update(id, updates);
|
||||
res.json(updatedUser);
|
||||
} catch (err) {
|
||||
@@ -244,12 +272,12 @@ router.put('/users/:id', auth.requireAuth, auth.requireAdmin, async (req, res) =
|
||||
router.delete('/users/:id', auth.requireAuth, auth.requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
|
||||
// Prevent deleting yourself
|
||||
if (parseInt(id) === req.user.id) {
|
||||
return res.status(400).json({ error: 'Cannot delete your own account' });
|
||||
}
|
||||
|
||||
|
||||
await db.users.delete(id);
|
||||
res.json({ success: true, message: 'User deleted successfully' });
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user