Merge pull request #66 from wheetazlab/feature-oidc-only-mode

feat: add OIDC-only mode to disable password authentication
This commit is contained in:
mauriceboe
2026-03-28 21:51:14 +01:00
committed by GitHub
12 changed files with 154 additions and 39 deletions

View File

@@ -94,7 +94,7 @@ router.put('/users/:id', (req: Request, res: Response) => {
router.delete('/users/:id', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (parseInt(req.params.id) === authReq.user.id) {
if (parseInt(req.params.id as string) === authReq.user.id) {
return res.status(400).json({ error: 'Cannot delete own account' });
}
@@ -122,16 +122,18 @@ router.get('/oidc', (_req: Request, res: Response) => {
client_id: get('oidc_client_id'),
client_secret_set: !!secret,
display_name: get('oidc_display_name'),
oidc_only: get('oidc_only') === 'true',
});
});
router.put('/oidc', (req: Request, res: Response) => {
const { issuer, client_id, client_secret, display_name } = req.body;
const { issuer, client_id, client_secret, display_name, oidc_only } = req.body;
const set = (key: string, val: string) => db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val || '');
set('oidc_issuer', issuer);
set('oidc_client_id', client_id);
if (client_secret !== undefined) set('oidc_client_secret', client_secret);
set('oidc_display_name', display_name);
set('oidc_only', oidc_only ? 'true' : 'false');
res.json({ success: true });
});

View File

@@ -59,6 +59,17 @@ function rateLimiter(maxAttempts: number, windowMs: number) {
}
const authLimiter = rateLimiter(10, RATE_LIMIT_WINDOW);
function isOidcOnlyMode(): boolean {
const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null;
const enabled = get('oidc_only') === 'true';
if (!enabled) return false;
const oidcConfigured = !!(
(process.env.OIDC_ISSUER || get('oidc_issuer')) &&
(process.env.OIDC_CLIENT_ID || get('oidc_client_id'))
);
return oidcConfigured;
}
function maskKey(key: string | null | undefined): string | null {
if (!key) return null;
if (key.length <= 8) return '--------';
@@ -89,6 +100,8 @@ router.get('/app-config', (_req: Request, res: Response) => {
(process.env.OIDC_ISSUER || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get() as { value: string } | undefined)?.value) &&
(process.env.OIDC_CLIENT_ID || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get() as { value: string } | undefined)?.value)
);
const oidcOnlySetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_only'").get() as { value: string } | undefined)?.value;
const oidcOnlyMode = oidcConfigured && oidcOnlySetting === 'true';
res.json({
allow_registration: isDemo ? false : allowRegistration,
has_users: userCount > 0,
@@ -96,6 +109,7 @@ router.get('/app-config', (_req: Request, res: Response) => {
has_maps_key: hasGoogleKey,
oidc_configured: oidcConfigured,
oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined,
oidc_only_mode: oidcOnlyMode,
allowed_file_types: (db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get() as { value: string } | undefined)?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv',
demo_mode: isDemo,
demo_email: isDemo ? 'demo@trek.app' : undefined,
@@ -118,6 +132,9 @@ router.post('/register', authLimiter, (req: Request, res: Response) => {
const { username, email, password } = req.body;
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
if (userCount > 0 && isOidcOnlyMode()) {
return res.status(403).json({ error: 'Password authentication is disabled. Please sign in with SSO.' });
}
if (userCount > 0) {
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined;
if (setting?.value === 'false') {
@@ -167,6 +184,10 @@ router.post('/register', authLimiter, (req: Request, res: Response) => {
});
router.post('/login', authLimiter, (req: Request, res: Response) => {
if (isOidcOnlyMode()) {
return res.status(403).json({ error: 'Password authentication is disabled. Please sign in with SSO.' });
}
const { email, password } = req.body;
if (!email || !password) {
@@ -205,6 +226,9 @@ router.get('/me', authenticate, (req: Request, res: Response) => {
router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (isOidcOnlyMode()) {
return res.status(403).json({ error: 'Password authentication is disabled.' });
}
if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@trek.app') {
return res.status(403).json({ error: 'Password change is disabled in demo mode.' });
}

View File

@@ -22,7 +22,7 @@ interface UnsplashSearchResponse {
const router = express.Router({ mergeParams: true });
router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId } = req.params;
const { tripId } = req.params
const { search, category, tag } = req.query;
let query = `
@@ -41,12 +41,12 @@ router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) =
if (category) {
query += ' AND p.category_id = ?';
params.push(category);
params.push(category as string);
}
if (tag) {
query += ' AND p.id IN (SELECT place_id FROM place_tags WHERE tag_id = ?)';
params.push(tag);
params.push(tag as string);
}
query += ' ORDER BY p.created_at DESC';
@@ -73,7 +73,7 @@ router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) =
});
router.post('/', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => {
const { tripId } = req.params;
const { tripId } = req.params
const {
name, description, lat, lng, address, category_id, price, currency,
@@ -107,13 +107,13 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name:
}
}
const place = getPlaceWithTags(placeId);
const place = getPlaceWithTags(Number(placeId));
res.status(201).json({ place });
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
});
router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId, id } = req.params;
const { tripId, id } = req.params
const placeCheck = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!placeCheck) {
@@ -126,7 +126,7 @@ router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response
router.get('/:id/image', authenticate, requireTripAccess, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const { tripId, id } = req.params
const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId) as Place | undefined;
if (!place) {
@@ -166,7 +166,7 @@ router.get('/:id/image', authenticate, requireTripAccess, async (req: Request, r
});
router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => {
const { tripId, id } = req.params;
const { tripId, id } = req.params
const existingPlace = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId) as Place | undefined;
if (!existingPlace) {
@@ -238,7 +238,7 @@ router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name
});
router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId, id } = req.params;
const { tripId, id } = req.params
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!place) {

View File

@@ -1,4 +1,4 @@
import cron from 'node-cron';
import cron, { type ScheduledTask } from 'node-cron';
import archiver from 'archiver';
import path from 'path';
import fs from 'fs';
@@ -23,7 +23,7 @@ interface BackupSettings {
keep_days: number;
}
let currentTask: cron.ScheduledTask | null = null;
let currentTask: ScheduledTask | null = null;
function loadSettings(): BackupSettings {
try {
@@ -110,7 +110,7 @@ function start(): void {
}
// Demo mode: hourly reset of demo user data
let demoTask: cron.ScheduledTask | null = null;
let demoTask: ScheduledTask | null = null;
function startDemoReset(): void {
if (demoTask) { demoTask.stop(); demoTask = null; }