Initial import of verae-staff-session from zapier monorepo
This commit is contained in:
commit
b0f2b8a261
8 changed files with 240 additions and 0 deletions
3
NATS.md
Normal file
3
NATS.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# NATS
|
||||||
|
|
||||||
|
No subjects. HTTP cookie only.
|
||||||
31
README.md
Normal file
31
README.md
Normal file
|
|
@ -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
|
||||||
|
```
|
||||||
3
SUMMARY.md
Normal file
3
SUMMARY.md
Normal file
|
|
@ -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.
|
||||||
11
package.json
Normal file
11
package.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/gate.js
Normal file
14
src/gate.js
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
127
src/server.js
Normal file
127
src/server.js
Normal file
|
|
@ -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 = `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>Staff sign-in</title>
|
||||||
|
<style>
|
||||||
|
:root { --accent:#4f46e5; --bg:#f4f5fb; --ink:#171a26; --muted:#6b7186; --line:#e5e7f0; }
|
||||||
|
* { box-sizing:border-box; }
|
||||||
|
body { margin:0; font:14px/1.45 -apple-system,"Segoe UI",sans-serif; background:var(--bg); color:var(--ink); }
|
||||||
|
#auth { min-height:100vh; display:grid; place-items:center; background:linear-gradient(160deg,#312e81 0%,#4f46e5 55%,#7c74f0 100%); }
|
||||||
|
.card { width:360px; background:#fff; border-radius:16px; padding:2rem; box-shadow:0 24px 64px rgba(17,12,60,.35); }
|
||||||
|
h1 { margin:0 0 .25rem; font-size:1.3rem; }
|
||||||
|
p { color:var(--muted); margin:0 0 1.2rem; }
|
||||||
|
label { display:block; font-size:.8rem; font-weight:700; margin:.8rem 0 .3rem; }
|
||||||
|
input { width:100%; padding:.6rem .75rem; border:1px solid var(--line); border-radius:8px; }
|
||||||
|
button { width:100%; margin-top:1.2rem; padding:.65rem; border:0; border-radius:8px; background:var(--accent); color:#fff; font-weight:700; cursor:pointer; }
|
||||||
|
.err { color:#dc2626; min-height:1.2em; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<section id="auth">
|
||||||
|
<form class="card" method="post" action="/login">
|
||||||
|
<h1>Staff sign-in</h1>
|
||||||
|
<p>One cookie covers CS, sales, accounting, and the staff plane on this host.</p>
|
||||||
|
<input type="hidden" name="next" id="next"/>
|
||||||
|
<label for="password">Staff key</label>
|
||||||
|
<input id="password" name="password" type="password" autocomplete="current-password" required/>
|
||||||
|
<p class="err" id="err"></p>
|
||||||
|
<button type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<script>
|
||||||
|
const q = new URLSearchParams(location.search);
|
||||||
|
document.getElementById('next').value = q.get('next') || '';
|
||||||
|
if (q.get('error')) document.getElementById('err').textContent = 'Wrong key.';
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
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`);
|
||||||
|
});
|
||||||
32
src/token.js
Normal file
32
src/token.js
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
19
test/token.test.js
Normal file
19
test/token.test.js
Normal file
|
|
@ -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;
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue