From 3052113d314d5c8b5c125f81463a54ec1bbb88b3 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Thu, 15 Oct 2026 11:15:46 +0000 Subject: [PATCH] Shared kiosk sessions: app kiosk registry, kiosk login, verify support, admin password apps gain kiosk_path/kiosk_caps (hvac registered first). One shared kiosk password mints a long-lived hnf_kiosk_session carrying only each registered app's kiosk caps; /verify answers for kiosk sessions; changing the password bumps an epoch that invalidates signed-in tablets. Co-Authored-By: Claude Sonnet 5.5 --- src/db.js | 17 +++++++++++ src/index.js | 2 ++ src/jwt.js | 11 +++++++ src/kiosk.js | 71 +++++++++++++++++++++++++++++++++++++++++++++ src/routes/admin.js | 20 +++++++++++++ src/routes/auth.js | 15 +++++++++- src/routes/kiosk.js | 46 +++++++++++++++++++++++++++++ 7 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 src/kiosk.js create mode 100644 src/routes/kiosk.js diff --git a/src/db.js b/src/db.js index b21be98..07ebca1 100644 --- a/src/db.js +++ b/src/db.js @@ -133,6 +133,13 @@ export async function initDb() { 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 max_session_hours INTEGER`) + // Kiosk registry: an app with a kiosk_path has a full-screen back-of-house + // screen the shared wall-tablet shell (portal /kiosk) can launch. kiosk_caps + // is the contract — the only capabilities a kiosk session is ever given for + // that app (never settings/admin ones). See auth/src/kiosk.js. + await pool.query(`ALTER TABLE apps ADD COLUMN IF NOT EXISTS kiosk_path TEXT`) + await pool.query(`ALTER TABLE apps ADD COLUMN IF NOT EXISTS kiosk_caps TEXT[]`) + await pool.query(`CREATE TABLE IF NOT EXISTS kiosk_config (key TEXT PRIMARY KEY, value TEXT NOT NULL)`) 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 pending_reg_email_idx ON pending_registrations (email)`) @@ -651,6 +658,16 @@ export async function initDb() { sort_order = EXCLUDED.sort_order `) + // Kiosk registrations — code is the source of truth, re-applied on every start. + // Add a line here when an app gets a /kiosk screen (and keep kiosk_caps to + // operational controls only: a shared back-of-house tablet must never hold a + // settings/manage capability). + for (const k of [ + { slug: 'hvac', path: '/hvac/kiosk', caps: ['view', 'control'] }, + ]) { + await pool.query('UPDATE apps SET kiosk_path = $1, kiosk_caps = $2 WHERE slug = $3', [k.path, k.caps, k.slug]) + } + // Seed first admin user if table is empty const { rows } = await pool.query('SELECT COUNT(*) FROM users') if (parseInt(rows[0].count) === 0) { diff --git a/src/index.js b/src/index.js index c88430c..8e82c87 100644 --- a/src/index.js +++ b/src/index.js @@ -6,6 +6,7 @@ import { authRoutes } from './routes/auth.js' import { adminRoutes } from './routes/admin.js' import { internalRoutes } from './routes/internal.js' import { registerRoutes } from './routes/register.js' +import { kioskRoutes } from './routes/kiosk.js' import { startSyncJob } from './sync.js' const app = Fastify({ logger: true, trustProxy: true }) @@ -20,6 +21,7 @@ app.get('/health', async () => ({ status: 'healthy' })) await app.register(authRoutes, { prefix: '/api/auth' }) await app.register(registerRoutes, { prefix: '/api/auth' }) +await app.register(kioskRoutes, { prefix: '/api/auth/kiosk' }) await app.register(adminRoutes, { prefix: '/api/auth/admin' }) await app.register(internalRoutes, { prefix: '/api/auth/internal' }) diff --git a/src/jwt.js b/src/jwt.js index 5ac67c6..3b2a31b 100644 --- a/src/jwt.js +++ b/src/jwt.js @@ -20,6 +20,17 @@ export async function signToken(payload) { .sign(secret) } +// Same signing as signToken but with an explicit lifetime — used for kiosk +// sessions, which are deliberately near-permanent (enter the password once per +// wall tablet). +export async function signTokenFor(payload, expiresIn) { + return new SignJWT(payload) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime(expiresIn) + .sign(secret) +} + export async function verifyToken(token) { const { payload } = await jwtVerify(token, secret) return payload diff --git a/src/kiosk.js b/src/kiosk.js new file mode 100644 index 0000000..0155cd8 --- /dev/null +++ b/src/kiosk.js @@ -0,0 +1,71 @@ +// Shared back-of-house kiosk sessions. One kiosk password (hashed in +// kiosk_config) unlocks a long-lived session whose token carries ONLY the +// capabilities each kiosk-enabled app registered in apps.kiosk_caps — so what a +// shared wall tablet can do is decided by each app's own kiosk screen design, +// not by a user/role. +// +// Deliberately its own cookie name (never hnf_session): sharing it caused a real +// collision between kiosk and staff logins on the same browser (hit with the +// hvac kiosk, 2026-08-26). Apps' backends read it as a fallback cookie. +import { pool } from './db.js' +import { signTokenFor, verifyToken } from './jwt.js' + +export const KIOSK_COOKIE = 'hnf_kiosk_session' +export const KIOSK_SESSION_SECONDS = 60 * 60 * 24 * 3650 // ~10 years, like hvac's own kiosk token +export const KIOSK_SESSION_EXPIRY = `${KIOSK_SESSION_SECONDS}s` + +export async function getKioskConfig(key) { + const { rows: [r] } = await pool.query('SELECT value FROM kiosk_config WHERE key = $1', [key]) + return r?.value ?? null +} + +export async function setKioskConfig(key, value) { + await pool.query( + `INSERT INTO kiosk_config (key, value) VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + [key, value] + ) +} + +async function currentEpoch() { + return Number(await getKioskConfig('epoch')) || 1 +} + +// Active apps that registered a kiosk screen. +export async function getKioskApps() { + const { rows } = await pool.query( + `SELECT slug, name, icon, theme_color, kiosk_path, kiosk_caps + FROM apps WHERE active = TRUE AND kiosk_path IS NOT NULL ORDER BY name` + ) + return rows +} + +export async function mintKioskToken() { + const apps = await getKioskApps() + return { + apps, + token: await signTokenFor({ + sub: 'kiosk', + name: 'Kiosk', + kiosk: true, + ke: await currentEpoch(), // bumped whenever the kiosk password changes + is_admin: false, + offsite_allowed: true, // physical on-site device — no IP check + apps: apps.map(a => a.slug), + caps: apps.flatMap(a => (a.kiosk_caps || []).map(c => `${a.slug}:${c}`)), + }, KIOSK_SESSION_EXPIRY), + } +} + +// Returns the verified kiosk payload, or null if there's no valid kiosk +// session (missing/invalid cookie, not a kiosk token, or signed before the +// password was last changed). +export async function readKioskSession(request) { + const token = request.cookies?.[KIOSK_COOKIE] + if (!token) return null + let payload + try { payload = await verifyToken(token) } catch { return null } + if (!payload.kiosk) return null + if (payload.ke !== await currentEpoch()) return null + return payload +} diff --git a/src/routes/admin.js b/src/routes/admin.js index f66ea6a..bb58abd 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -2,6 +2,7 @@ import { pool } from '../db.js' import { hashPassword, verifyToken, verifyPassword } from '../jwt.js' import { getAllDepartments, getLocationsByIds, getSyncConfig, findEmployeeByEmail, getEmployeeDepartments } from '../workforce.js' import { syncAllWorkforceUsers } from '../sync.js' +import { getKioskApps, getKioskConfig, setKioskConfig } from '../kiosk.js' async function requireAdmin(request, reply) { const token = request.cookies?.hnf_session @@ -108,6 +109,25 @@ export async function adminRoutes(app) { return reply.status(204).send() }) + // ── Kiosk ────────────────────────────────────────────────────────────────── + + app.get('/kiosk', async () => ({ + configured: !!(await getKioskConfig('password_hash')), + apps: (await getKioskApps()).map(a => ({ slug: a.slug, name: a.name, kiosk_path: a.kiosk_path, kiosk_caps: a.kiosk_caps || [] })), + })) + + // Changing the password bumps the epoch, which invalidates every signed-in + // tablet at its next check (portal kiosk home / auth verify) — see kiosk.js. + app.post('/kiosk-password', async (request, reply) => { + const { password } = request.body || {} + if (typeof password !== 'string' || password.length < 6) { + return reply.status(400).send({ error: 'Password must be at least 6 characters' }) + } + await setKioskConfig('password_hash', await hashPassword(password)) + await setKioskConfig('epoch', String((Number(await getKioskConfig('epoch')) || 1) + 1)) + return { ok: true } + }) + // ── Apps registry ────────────────────────────────────────────────────────── app.get('/apps', async () => { diff --git a/src/routes/auth.js b/src/routes/auth.js index 8b9533d..4a84c3a 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -1,6 +1,7 @@ import { pool } from '../db.js' import { verifyPassword, signToken, verifyToken } from '../jwt.js' import { isOnsite } from '../ip-check.js' +import { readKioskSession, getKioskApps } from '../kiosk.js' const DOMAIN = process.env.DOMAIN || 'localhost' const SESSION_DAYS = parseInt(process.env.SESSION_DAYS || '30') @@ -235,7 +236,19 @@ export async function authRoutes(app) { // take effect on the next request without requiring re-login. app.get('/verify', async (request, reply) => { const token = request.cookies?.hnf_session - if (!token) return reply.status(401).send({ error: 'Not authenticated' }) + if (!token) { + // No staff session — a shared kiosk tablet has no user, so answer for it + // directly: only apps that registered a kiosk screen, only their + // registered kiosk caps (resolved live from the registry). + const kiosk = await readKioskSession(request) + const { app: kioskApp } = request.query + if (kiosk && kioskApp) { + const a = (await getKioskApps()).find(x => x.slug === kioskApp) + if (!a) return reply.status(403).send({ error: 'Access denied' }) + return { user_id: 0, email: 'kiosk', name: 'Kiosk', is_admin: false, kiosk: true, app: kioskApp, caps: a.kiosk_caps || [] } + } + return reply.status(401).send({ error: 'Not authenticated' }) + } let payload try { payload = await verifyToken(token) } diff --git a/src/routes/kiosk.js b/src/routes/kiosk.js new file mode 100644 index 0000000..6addde4 --- /dev/null +++ b/src/routes/kiosk.js @@ -0,0 +1,46 @@ +import { verifyPassword } from '../jwt.js' +import { cookieOpts, makeIpThrottle } from './auth.js' +import { + KIOSK_COOKIE, KIOSK_SESSION_SECONDS, getKioskConfig, mintKioskToken, readKioskSession, +} from '../kiosk.js' + +// Kiosk login is a single shared password, so throttle hard per IP. +const throttle = makeIpThrottle(10, 15 * 60_000) + +function kioskCookieOpts(request, clear = false) { + return { ...cookieOpts(request, clear), maxAge: clear ? 0 : KIOSK_SESSION_SECONDS } +} + +const publicApp = a => ({ slug: a.slug, name: a.name, icon: a.icon, theme_color: a.theme_color, kiosk_path: a.kiosk_path }) + +export async function kioskRoutes(app) { + // POST /api/auth/kiosk/login — the shared kiosk password. + app.post('/login', async (request, reply) => { + if (!throttle(request)) return reply.status(429).send({ error: 'Too many attempts — try again later' }) + const { password } = request.body || {} + if (!password || typeof password !== 'string') return reply.status(400).send({ error: 'Password required' }) + + const hash = await getKioskConfig('password_hash') + if (!hash) return reply.status(503).send({ error: 'Kiosk login has not been set up yet — an admin needs to set a kiosk password in the portal.' }) + if (!(await verifyPassword(password, hash))) return reply.status(401).send({ error: 'Incorrect password' }) + + const { token, apps } = await mintKioskToken() + reply.setCookie(KIOSK_COOKIE, token, kioskCookieOpts(request)) + return { apps: apps.map(publicApp) } + }) + + // GET /api/auth/kiosk/apps — the home screen's app list. Also re-mints the + // token, so apps registered after a tablet signed in appear (and registry cap + // changes take effect) without re-entering the password. + app.get('/apps', async (request, reply) => { + if (!(await readKioskSession(request))) return reply.status(401).send({ error: 'Not signed in' }) + const { token, apps } = await mintKioskToken() + reply.setCookie(KIOSK_COOKIE, token, kioskCookieOpts(request)) + return { apps: apps.map(publicApp) } + }) + + app.post('/logout', async (request, reply) => { + reply.clearCookie(KIOSK_COOKIE, kioskCookieOpts(request, true)) + return { ok: true } + }) +}