Add per-app session timeout: max_session_hours column on apps, enforced in /verify

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-07 12:57:13 +00:00
parent 3fae52c36b
commit 788628485b
3 changed files with 23 additions and 6 deletions

View file

@ -110,6 +110,7 @@ export async function initDb() {
await pool.query(`ALTER TABLE apps ADD COLUMN IF NOT EXISTS category VARCHAR(100)`) await pool.query(`ALTER TABLE apps ADD COLUMN IF NOT EXISTS category VARCHAR(100)`)
await pool.query(`ALTER TABLE apps ADD COLUMN IF NOT EXISTS internal_host TEXT`) await pool.query(`ALTER TABLE apps ADD COLUMN IF NOT EXISTS internal_host TEXT`)
await pool.query(`ALTER TABLE apps ADD COLUMN IF NOT EXISTS internal_port INTEGER DEFAULT 3080`) await pool.query(`ALTER TABLE apps ADD COLUMN IF NOT EXISTS internal_port INTEGER DEFAULT 3080`)
await pool.query(`ALTER TABLE apps ADD COLUMN IF NOT EXISTS max_session_hours INTEGER`)
await pool.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS workforce_user_id TEXT`) await pool.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS workforce_user_id TEXT`)
await pool.query(`CREATE UNIQUE INDEX IF NOT EXISTS users_workforce_user_id_idx ON users (workforce_user_id) WHERE workforce_user_id IS NOT NULL`) await pool.query(`CREATE UNIQUE INDEX IF NOT EXISTS users_workforce_user_id_idx ON users (workforce_user_id) WHERE workforce_user_id IS NOT NULL`)
await pool.query(`CREATE UNIQUE INDEX IF NOT EXISTS pending_reg_email_idx ON pending_registrations (email)`) await pool.query(`CREATE UNIQUE INDEX IF NOT EXISTS pending_reg_email_idx ON pending_registrations (email)`)

View file

@ -129,13 +129,17 @@ export async function adminRoutes(app) {
}) })
app.patch('/apps/:slug', async (request, reply) => { app.patch('/apps/:slug', async (request, reply) => {
const { active } = request.body || {} const { active, max_session_hours } = request.body || {}
const { rows: [app] } = await pool.query( const updates = []; const values = []
'UPDATE apps SET active = $1 WHERE slug = $2 RETURNING *', if (active !== undefined) updates.push(`active = $${values.push(active)}`)
[active, request.params.slug] if (max_session_hours !== undefined) updates.push(`max_session_hours = $${values.push(max_session_hours ?? null)}`)
if (!updates.length) return reply.status(400).send({ error: 'Nothing to update' })
values.push(request.params.slug)
const { rows: [a] } = await pool.query(
`UPDATE apps SET ${updates.join(', ')} WHERE slug = $${values.length} RETURNING *`, values
) )
if (!app) return reply.status(404).send({ error: 'App not found' }) if (!a) return reply.status(404).send({ error: 'App not found' })
return app return a
}) })
// ── Roles ────────────────────────────────────────────────────────────────── // ── Roles ──────────────────────────────────────────────────────────────────

View file

@ -171,6 +171,18 @@ export async function authRoutes(app) {
if (!user) return reply.status(403).send({ error: 'Access denied' }) if (!user) return reply.status(403).send({ error: 'Access denied' })
if (appSlug) {
const { rows: [appRow] } = await pool.query(
'SELECT max_session_hours FROM apps WHERE slug = $1 AND active = true', [appSlug]
)
if (appRow?.max_session_hours) {
const ageHours = (Date.now() / 1000 - payload.iat) / 3600
if (ageHours > appRow.max_session_hours) {
return reply.status(401).send({ error: 'Session expired' })
}
}
}
if (!user.offsite_allowed) { if (!user.offsite_allowed) {
const clientIP = request.headers['x-real-ip'] || request.ip const clientIP = request.headers['x-real-ip'] || request.ip
if (!(await isOnsite(clientIP))) { if (!(await isOnsite(clientIP))) {