[object Object]

← back to Rentv 2026

RENTV Path A: additive session-cookie login + CSRF + service-token, v0.21.0 — TK-10257

b6a773ffe7e76110ced9874396c42b40c2ac1c88 · 2026-08-05 15:03:29 -0700 · Steve Abrams

- session store (in-mem, 256-bit opaque cookie id), /login page + /api/login|logout|whoami (pre-gate)
- gate now: session cookie → Bearer service-token → Basic (nginx/loopback, UNCHANGED) → clean /login redirect for unauth HTML, 401 for API
- CSRF (double-submit X-CSRF-Token) enforced ONLY on session-auth mutations; Basic/service/open immune (loopback-safe); csrf.js fetch-patch injected in-head via sendPage
- guardrails: SameSite=Strict, HttpOnly+Secure(behind https), session-id regen on login, idle 8h + abs 30d expiry
- ADDITIVE: ships behind the still-up nginx wall with zero breakage; dropping the wall (Steve-gated Phase 2) activates the clean login as front door

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit b6a773ffe7e76110ced9874396c42b40c2ac1c88
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Aug 5 15:03:29 2026 -0700

    RENTV Path A: additive session-cookie login + CSRF + service-token, v0.21.0 — TK-10257
    
    - session store (in-mem, 256-bit opaque cookie id), /login page + /api/login|logout|whoami (pre-gate)
    - gate now: session cookie → Bearer service-token → Basic (nginx/loopback, UNCHANGED) → clean /login redirect for unauth HTML, 401 for API
    - CSRF (double-submit X-CSRF-Token) enforced ONLY on session-auth mutations; Basic/service/open immune (loopback-safe); csrf.js fetch-patch injected in-head via sendPage
    - guardrails: SameSite=Strict, HttpOnly+Secure(behind https), session-id regen on login, idle 8h + abs 30d expiry
    - ADDITIVE: ships behind the still-up nginx wall with zero breakage; dropping the wall (Steve-gated Phase 2) activates the clean login as front door
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 package-lock.json |  4 +--
 package.json      |  2 +-
 public/login.html | 69 +++++++++++++++++++++++++++++++++++++++++++
 server.js         | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++---
 4 files changed, 156 insertions(+), 7 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index 70719ec0..1eb90f46 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "rentv",
-  "version": "0.20.1",
+  "version": "0.21.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "rentv",
-      "version": "0.20.1",
+      "version": "0.21.0",
       "dependencies": {
         "express": "^4.21.2",
         "pg": "^8.22.0"
diff --git a/package.json b/package.json
index 4410cd46..02ad87a3 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "rentv",
-  "version": "0.20.1",
+  "version": "0.21.0",
   "private": true,
   "main": "server.js",
   "scripts": {
diff --git a/public/login.html b/public/login.html
new file mode 100644
index 00000000..759cbadb
--- /dev/null
+++ b/public/login.html
@@ -0,0 +1,69 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Sign in — RENTV.com</title>
+<meta name="robots" content="noindex,nofollow">
+<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700&family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
+<style>
+  :root{--red:#c8102e;--ink:#14171a;--sub:#5b636b;--line:#e4e7ea;--wash:#f5f6f8;--sans:'Inter',system-ui,sans-serif;--serif:'Playfair Display',Georgia,serif}
+  *{box-sizing:border-box;margin:0;padding:0}
+  body{font-family:var(--sans);color:var(--ink);background:var(--wash);min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
+  .card{background:#fff;border:1px solid var(--line);border-radius:16px;box-shadow:0 24px 60px rgba(20,23,26,.08);width:100%;max-width:400px;padding:36px 34px}
+  .logo{font-family:var(--serif);font-weight:700;font-size:30px;letter-spacing:-1px;text-align:center}.logo span{color:var(--red)}
+  .logo small{display:block;font-size:10px;font-weight:600;letter-spacing:3px;color:var(--sub);text-transform:uppercase;margin-top:3px;font-family:var(--sans)}
+  h1{font-family:var(--serif);font-size:22px;text-align:center;margin:22px 0 4px}
+  p.sub{text-align:center;color:var(--sub);font-size:13px;margin-bottom:22px}
+  label{display:block;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--sub);margin:14px 0 5px}
+  input{width:100%;font-family:var(--sans);font-size:15px;padding:11px 13px;border:1px solid var(--line);border-radius:9px;background:#fff}
+  input:focus{outline:none;border-color:var(--ink)}
+  button{width:100%;margin-top:22px;font-family:var(--sans);font-size:15px;font-weight:700;padding:12px;border:none;border-radius:9px;background:var(--red);color:#fff;cursor:pointer}
+  button:disabled{opacity:.6;cursor:default}
+  .msg{margin-top:14px;font-size:13px;text-align:center;min-height:18px}
+  .msg.err{color:var(--red)}
+  .foot{margin-top:20px;text-align:center;font-size:11px;color:var(--sub)}
+</style>
+</head>
+<body>
+  <div class="card">
+    <div class="logo">REN<span>TV</span>.com<small>Commercial Real Estate News</small></div>
+    <h1>Sign in</h1>
+    <p class="sub">Enter your credentials to access the site.</p>
+    <form id="f" autocomplete="on">
+      <label for="u">Username</label>
+      <input id="u" name="username" autocomplete="username" autofocus required>
+      <label for="p">Password</label>
+      <input id="p" name="password" type="password" autocomplete="current-password" required>
+      <button id="btn" type="submit">Sign in</button>
+      <div class="msg" id="msg"></div>
+    </form>
+    <div class="foot">© RENTV.com — Commercial Real Estate News</div>
+  </div>
+<script>
+const $=s=>document.querySelector(s);
+const nextParam=new URLSearchParams(location.search).get('next')||'';
+function safeNext(role){
+  // only allow same-origin relative paths; default by role
+  if(nextParam && nextParam.startsWith('/') && !nextParam.startsWith('//')) return nextParam;
+  return role==='admin' ? '/backend' : '/';
+}
+$('#f').addEventListener('submit', async (e)=>{
+  e.preventDefault();
+  const btn=$('#btn'), msg=$('#msg'); msg.className='msg'; msg.textContent='';
+  btn.disabled=true; btn.textContent='Signing in…';
+  try{
+    const r=await fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'},
+      body:JSON.stringify({username:$('#u').value,password:$('#p').value})});
+    const j=await r.json();
+    if(!r.ok||!j.ok) throw new Error(j.error||('HTTP '+r.status));
+    msg.textContent='Welcome — redirecting…';
+    location.href=safeNext(j.role);
+  }catch(err){
+    msg.className='msg err'; msg.textContent=err.message||'Sign-in failed';
+    btn.disabled=false; btn.textContent='Sign in';
+  }
+});
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
index 998ee5cf..980d5574 100644
--- a/server.js
+++ b/server.js
@@ -45,14 +45,88 @@ for (const [role, list] of Object.entries(ROLE_CREDS)) {
   for (const c of list) CRED_ROLE.set('Basic ' + Buffer.from(c).toString('base64'), role);
 }
 app.get('/api/health', (_q, r) => r.json({ ok: true, at: new Date().toISOString() }));
-// Global authentication gate (any valid login passes) + role resolution onto req.role.
+// ── SESSION AUTH (Path A — TK-10257, DTD verdict A 2026-08-05). ADDITIVE over Basic-Auth:
+//    Basic still works (the nginx wall's forwarded creds + every server-side/loopback caller),
+//    so this ships behind the still-up nginx wall with zero breakage; dropping that wall
+//    (Steve-gated) then makes the clean login page the front door. In-memory store — a single
+//    admin re-logging-in after a deploy is acceptable; the cookie is a 256-bit opaque id looked
+//    up server-side (unforgeable, no signing needed). Guardrails: SameSite=Strict + per-session
+//    CSRF token on mutating routes, session-id regenerated on login, HttpOnly+Secure, idle+abs
+//    expiry, plus a dedicated Bearer service-token path for loopback callers. ──
+const crypto = require('node:crypto');
+const SESS = new Map(); // id -> { role, user, created, seen, csrf }
+const SESS_IDLE_MS = 8 * 3600e3;       // 8h idle timeout
+const SESS_ABS_MS = 30 * 24 * 3600e3;  // 30d absolute lifetime
+const SERVICE_TOKEN = process.env.RENTV_SERVICE_TOKEN || ''; // optional dedicated loopback/service creds
+const newId = () => crypto.randomBytes(32).toString('hex');
+function parseCookies(req) {
+  const out = {}; for (const p of String(req.headers.cookie || '').split(';')) {
+    const i = p.indexOf('='); if (i < 0) continue; out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim());
+  } return out;
+}
+function sessionOf(req) {
+  const id = parseCookies(req).rentv_sess; if (!id) return null;
+  const s = SESS.get(id); if (!s) return null;
+  const now = Date.now();
+  if (now - s.seen > SESS_IDLE_MS || now - s.created > SESS_ABS_MS) { SESS.delete(id); return null; }
+  s.seen = now; s._id = id; return s;
+}
+setInterval(() => { const now = Date.now(); for (const [id, s] of SESS) if (now - s.seen > SESS_IDLE_MS || now - s.created > SESS_ABS_MS) SESS.delete(id); }, 3600e3).unref?.();
+const cookieFlags = (req) => `; HttpOnly; SameSite=Strict; Path=/${(req.headers['x-forwarded-proto'] === 'https' || req.secure) ? '; Secure' : ''}`;
+
+// Login page + endpoints — registered BEFORE the gate so they're reachable unauthenticated.
+app.get('/login', (_q, r) => sendPage(r, path.join(PUB, 'login.html')));
+app.get('/csrf.js', (_q, r) => {
+  r.type('application/javascript').set('Cache-Control', 'no-store').send(
+    "(function(){var m=document.cookie.match(/(?:^|;\\s*)rentv_csrf=([^;]+)/);var t=m?decodeURIComponent(m[1]):'';if(!t)return;var of=window.fetch;window.fetch=function(u,o){o=o||{};var meth=((o.method||'GET')+'').toUpperCase();if(['POST','PUT','DELETE','PATCH'].indexOf(meth)>=0){var h=new Headers(o.headers||{});h.set('X-CSRF-Token',t);o.headers=h;}return of(u,o);};})();");
+});
+app.post('/api/login', (req, res) => {
+  const b = req.body || {};
+  const cred = 'Basic ' + Buffer.from(String(b.username || '') + ':' + String(b.password || '')).toString('base64');
+  const role = CRED_ROLE.get(cred);
+  if (!role) return res.status(401).json({ ok: false, error: 'Invalid username or password' });
+  const id = newId(), csrf = newId(), now = Date.now();          // fresh id on every login = no session fixation
+  SESS.set(id, { role, user: String(b.username), created: now, seen: now, csrf });
+  const maxAge = Math.floor(SESS_ABS_MS / 1000), f = cookieFlags(req);
+  res.setHeader('Set-Cookie', [
+    `rentv_sess=${id}; Max-Age=${maxAge}${f}`,
+    `rentv_csrf=${csrf}; Max-Age=${maxAge}${f.replace('; HttpOnly', '')}`, // readable by csrf.js (double-submit)
+  ]);
+  res.json({ ok: true, role });
+});
+app.post('/api/logout', (req, res) => {
+  const id = parseCookies(req).rentv_sess; if (id) SESS.delete(id);
+  res.setHeader('Set-Cookie', ['rentv_sess=; Max-Age=0; HttpOnly; Path=/', 'rentv_csrf=; Max-Age=0; Path=/']);
+  res.json({ ok: true });
+});
+app.get('/api/whoami', (req, res) => { const s = sessionOf(req); res.json({ authed: !!s, role: s ? s.role : null, via: s ? 'session' : null }); });
+
+// Global authentication gate — session cookie → service token → Basic → clean-login redirect.
 app.use((req, res, next) => {
-  if (process.env.OPEN === '1') { req.role = 'admin'; return next(); } // local-preview bypass; prod never sets OPEN
-  const role = CRED_ROLE.get(req.headers.authorization || '');
-  if (role) { req.role = role; return next(); }
+  if (process.env.OPEN === '1') { req.role = 'admin'; req.authVia = 'open'; return next(); } // local-preview bypass
+  const s = sessionOf(req);
+  if (s) { req.role = s.role; req.authVia = 'session'; req.session = s; return next(); }
+  const authz = req.headers.authorization || '';
+  if (SERVICE_TOKEN && authz === 'Bearer ' + SERVICE_TOKEN) { req.role = 'admin'; req.authVia = 'service'; return next(); }
+  const role = CRED_ROLE.get(authz);                             // Basic — nginx-forwarded creds + existing callers
+  if (role) { req.role = role; req.authVia = 'basic'; return next(); }
+  // Unauthenticated: clean login page for a browser page-load; 401 for API/XHR/curl (Basic-compatible).
+  if (req.method === 'GET' && !req.path.startsWith('/api/') && String(req.headers.accept || '').includes('text/html')) {
+    return res.redirect(302, '/login?next=' + encodeURIComponent(req.originalUrl));
+  }
   res.set('WWW-Authenticate', 'Basic realm="RENTV"');
   return res.status(401).send('Authentication required');
 });
+// CSRF — enforced ONLY for SESSION-authenticated mutations (Basic/service/open are CSRF-immune;
+// dormant while the nginx wall keeps everyone on Basic). Double-submit: X-CSRF-Token must equal
+// the session's csrf (csrf.js attaches it automatically to every mutating fetch).
+app.use((req, res, next) => {
+  if (req.authVia !== 'session') return next();
+  if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) return next();
+  const tok = req.headers['x-csrf-token'] || '';
+  if (req.session && tok && tok === req.session.csrf) return next();
+  return res.status(403).json({ ok: false, error: 'CSRF token missing or invalid — reload the page' });
+});
 // Admin-only gate: 403 for user-tier logins. Guards all INTERNAL data endpoints + shells.
 function adminOnly(req, res, next) {
   if (req.role === 'admin') return next();
@@ -108,6 +182,12 @@ function sendPage(res, absFile) {
     html = html.indexOf('</head>') !== -1 ? html.replace('</head>', DAYNIGHT_PREAPPLY + '</head>') : DAYNIGHT_PREAPPLY + html;
     html = closeBody() ? html.replace('</body>', DAYNIGHT_TAG + '</body>') : html + DAYNIGHT_TAG;
   }
+  // CSRF fetch-patch (Path A) — attaches X-CSRF-Token to mutating fetches when a session csrf
+  // cookie is present (inert under Basic auth). In <head> so it patches window.fetch first.
+  if (html.indexOf('/csrf.js') === -1) {
+    const t = '<script src="/csrf.js"></script>';
+    html = html.indexOf('</head>') !== -1 ? html.replace('</head>', t + '</head>') : t + html;
+  }
   if (!INTERNAL_PAGE.test(absFile) && html.indexOf('rentv-footer') === -1) {
     html = closeBody() ? html.replace('</body>', FOOTER_TAG + '</body>') : html + FOOTER_TAG;
   }

← bf2e528d Cody-gate: Deal Desk corpus /find is HONEST — the corpus IS  ·  back to Rentv 2026  ·  auto-save: 2026-08-05T15:13:13 (8 files) — data/closings-ove 7701c773 →