From b0f2b8a2615a6fbcf303fb65baf3545c51b82696 Mon Sep 17 00:00:00 2001 From: George Lambert Date: Fri, 11 Sep 2026 19:55:09 -0400 Subject: [PATCH] Initial import of verae-staff-session from zapier monorepo --- NATS.md | 3 ++ README.md | 31 +++++++++++ SUMMARY.md | 3 ++ package.json | 11 ++++ src/gate.js | 14 +++++ src/server.js | 127 +++++++++++++++++++++++++++++++++++++++++++++ src/token.js | 32 ++++++++++++ test/token.test.js | 19 +++++++ 8 files changed, 240 insertions(+) create mode 100644 NATS.md create mode 100644 README.md create mode 100644 SUMMARY.md create mode 100644 package.json create mode 100644 src/gate.js create mode 100644 src/server.js create mode 100644 src/token.js create mode 100644 test/token.test.js diff --git a/NATS.md b/NATS.md new file mode 100644 index 0000000..c03cc4b --- /dev/null +++ b/NATS.md @@ -0,0 +1,3 @@ +# NATS + +No subjects. HTTP cookie only. diff --git a/README.md b/README.md new file mode 100644 index 0000000..baebc4b --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# verae-staff-session + +Shared cookie login for CS / sales / accounting / access-staff HTML. + +**Forgejo:** https://git.georgelambert.org/marchon/verae-staff-session + +Port `:3027`. Set `STAFF_AUTH=1` on the department servers and `STAFF_SESSION_URL=http://127.0.0.1:3027`. Cookie host is the browser host (ports share `127.0.0.1`). JSON APIs stay open unless you also send `x-staff-key`. + +Default key: `STAFF_KEY` or `ADMIN_KEY` or `admin-dev-key`. + +## Multiple hostnames + +Cookies are host-scoped. On one operator box (`127.0.0.1`) that is enough. For several DNS names, put one reverse proxy in front and set `STAFF_COOKIE_DOMAIN`: + +```nginx +server { + server_name staff.example.com; + location /session/ { proxy_pass http://127.0.0.1:3027/; } + location /cs/ { proxy_pass http://127.0.0.1:3011/; } + location /sales/ { proxy_pass http://127.0.0.1:3012/; } + location /acct/ { proxy_pass http://127.0.0.1:3013/; } + location /staff/ { proxy_pass http://127.0.0.1:3025/; } +} +``` + +```bash +STAFF_COOKIE_DOMAIN=.example.com +STAFF_COOKIE_SECURE=1 +STAFF_SESSION_URL=https://staff.example.com/session +STAFF_AUTH=1 +``` diff --git a/SUMMARY.md b/SUMMARY.md new file mode 100644 index 0000000..7ecac15 --- /dev/null +++ b/SUMMARY.md @@ -0,0 +1,3 @@ +# verae-staff-session + +HMAC staff cookie (`staff_session`) so CS, sales, accounting, and access-staff HTML share one login on the operator host. diff --git a/package.json b/package.json new file mode 100644 index 0000000..e378952 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "verae-staff-session", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Shared staff cookie login for CS / sales / accounting / access-staff HTML", + "scripts": { + "start": "node src/server.js", + "test": "node --test test/*.test.js" + } +} diff --git a/src/gate.js b/src/gate.js new file mode 100644 index 0000000..25e34a6 --- /dev/null +++ b/src/gate.js @@ -0,0 +1,14 @@ +import { allowed } from './token.js'; + +/** Redirect HTML to the staff login when STAFF_AUTH=1. JSON APIs stay open unless STAFF_AUTH_JSON=1. */ +export function staffHtmlGuard(req, res, url) { + if (process.env.STAFF_AUTH !== '1') return false; + const html = req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html'); + if (!html) return false; + if (allowed(req)) return false; + const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, ''); + const next = `http://${req.headers.host || '127.0.0.1'}${url.pathname}`; + res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent(next)}` }); + res.end(); + return true; +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..809c6ce --- /dev/null +++ b/src/server.js @@ -0,0 +1,127 @@ +#!/usr/bin/env node +import http from 'node:http'; +import { cookieHeader, sessionToken, staffKey } from './token.js'; + +const PORT = Number(process.env.PORT || 3027); + +const LOGIN = ` + + + + + Staff sign-in + + + +
+
+

Staff sign-in

+

One cookie covers CS, sales, accounting, and the staff plane on this host.

+ + + +

+ +
+
+ + +`; + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url || '/', `http://127.0.0.1:${PORT}`); + const json = (code, obj) => { + res.writeHead(code, { 'content-type': 'application/json' }); + res.end(JSON.stringify(obj)); + }; + const IAM = (process.env.STAFF_IAM_URL || '').replace(/\/$/, ''); + if (IAM && req.method === 'GET' && (url.pathname === '/' || url.pathname === '/login')) { + const next = url.searchParams.get('next') || ''; + res.writeHead(302, { location: `${IAM}/login?next=${encodeURIComponent(next)}` }); + return res.end(); + } + if (IAM && (url.pathname === '/check' || url.pathname === '/login' || url.pathname === '/logout')) { + const target = `${IAM}${url.pathname}${url.search}`; + const r = await fetch(target, { + method: req.method, + headers: { cookie: req.headers.cookie || '', authorization: req.headers.authorization || '', 'content-type': req.headers['content-type'] || '' }, + body: req.method === 'GET' ? undefined : await new Promise((resolve) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => resolve(Buffer.concat(chunks))); + }), + redirect: 'manual', + }); + const buf = Buffer.from(await r.arrayBuffer()); + const headers = { 'content-type': r.headers.get('content-type') || 'application/json' }; + const sc = r.headers.get('set-cookie'); + if (sc) headers['set-cookie'] = sc; + const loc = r.headers.get('location'); + if (loc) headers.location = loc; + res.writeHead(r.status, headers); + return res.end(buf); + } + if (req.method === 'GET' && url.pathname === '/health') { + return json(200, { ok: true, role: 'verae-staff-session', iam: Boolean(IAM) }); + } + if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/login')) { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + return res.end(LOGIN); + } + if (req.method === 'GET' && url.pathname === '/check') { + const raw = req.headers.cookie || ''; + const m = /(?:^|; )staff_session=([^;]+)/.exec(raw); + return json(m && m[1] === sessionToken() ? 200 : 401, { ok: Boolean(m && m[1] === sessionToken()) }); + } + if (req.method === 'POST' && url.pathname === '/login') { + const chunks = []; + for await (const c of req) chunks.push(c); + const text = Buffer.concat(chunks).toString('utf8'); + let password = ''; + let next = '/'; + if ((req.headers['content-type'] || '').includes('json')) { + const body = JSON.parse(text || '{}'); + password = body.password || ''; + next = body.next || '/'; + } else { + const params = new URLSearchParams(text); + password = params.get('password') || ''; + next = params.get('next') || '/'; + } + if (password !== staffKey()) { + res.writeHead(302, { location: '/login?error=1' }); + return res.end(); + } + const loc = next.startsWith('http') || next.startsWith('/') ? next : '/'; + res.writeHead(302, { 'set-cookie': cookieHeader(), location: loc }); + return res.end(); + } + if (req.method === 'POST' && url.pathname === '/logout') { + res.writeHead(302, { + 'set-cookie': 'staff_session=; Path=/; Max-Age=0', + location: '/login', + }); + return res.end(); + } + json(404, { error: 'not found' }); +}); + +server.listen(PORT, '0.0.0.0', () => { + process.stdout.write(`verae-staff-session http://0.0.0.0:${PORT}/\n`); +}); diff --git a/src/token.js b/src/token.js new file mode 100644 index 0000000..6d3d029 --- /dev/null +++ b/src/token.js @@ -0,0 +1,32 @@ +import crypto from 'node:crypto'; + +export function staffKey() { + return process.env.STAFF_KEY || process.env.ADMIN_KEY || 'admin-dev-key'; +} + +export function sessionToken() { + return crypto.createHmac('sha256', staffKey()).update('verae-staff').digest('hex'); +} + +export function cookieHeader() { + let s = `staff_session=${sessionToken()}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`; + const domain = process.env.STAFF_COOKIE_DOMAIN; + if (domain) s += `; Domain=${domain}`; + if (process.env.STAFF_COOKIE_SECURE === '1') s += '; Secure'; + return s; +} + +export function cookieOk(req) { + const raw = req.headers?.cookie || ''; + const m = /(?:^|; )staff_session=([^;]+)/.exec(raw); + return Boolean(m && m[1] === sessionToken()); +} + +export function headerOk(req) { + const k = req.headers?.['x-staff-key']; + return k === staffKey(); +} + +export function allowed(req) { + return cookieOk(req) || headerOk(req); +} diff --git a/test/token.test.js b/test/token.test.js new file mode 100644 index 0000000..38177fe --- /dev/null +++ b/test/token.test.js @@ -0,0 +1,19 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { sessionToken, cookieOk, headerOk, cookieHeader } from '../src/token.js'; + +test('cookie matches HMAC of staff key', () => { + const tok = sessionToken(); + assert.equal(tok.length, 64); + assert.equal(cookieOk({ headers: { cookie: `staff_session=${tok}` } }), true); + assert.equal(cookieOk({ headers: { cookie: 'staff_session=nope' } }), false); + assert.equal(headerOk({ headers: { 'x-staff-key': process.env.STAFF_KEY || 'admin-dev-key' } }), true); +}); + +test('cookie Domain is optional', () => { + delete process.env.STAFF_COOKIE_DOMAIN; + assert.equal(cookieHeader().includes('Domain='), false); + process.env.STAFF_COOKIE_DOMAIN = '.example.com'; + assert.match(cookieHeader(), /Domain=\.example.com/); + delete process.env.STAFF_COOKIE_DOMAIN; +});