logicsrc/apps/pwa/src/lib/session.mjs
Anthony Ettinger 9ba044577f
Some checks failed
CI / build (push) Has been cancelled
test / test (push) Has been cancelled
feat(pwa): logicsrc credentials app — real auth + Turso, redesigned; retire commandboard-api credshare
Adds apps/pwa: an Express + libSQL/Turso app that is now the home of team
credential sharing, with the moshcode-style auth stack ported and reskinned to
match logicsrc.com (light theme, Inter, green accent).

apps/pwa
- auth: email/password (scrypt), passkeys (WebAuthn), CoinPay OAuth, cookie
  sessions, and lsk_ API keys for the CLI via a loopback OAuth-PKCE flow
  (/cli/authorize + /cli/token). Ported from the moshcode PWA.
- credshare API (/api/credshare/*): teams, members, invites, vaults, sealed
  grants, ciphertext secrets, audit — authed by session OR Bearer lsk_ key.
  Zero-knowledge: only ciphertext + sealed vault keys + public keys stored.
- teams dashboard, accept-invite, and settings (API keys) pages, server-rendered
  in the LogicSRC brand (lib/html.mjs).
- migrations (libSQL) 001_auth + 002_credshare, migrate-on-boot; Turso via
  TURSO_DATABASE_URL / TURSO_AUTH_TOKEN, or a local file db for dev.
- trimmed moshcode-specific approvals/credits/push/deliver.

CLI
- `logicsrc login` now does browser loopback OAuth-PKCE against the app and
  stores an lsk_ token (email-OTP removed); --token for CI. Client repointed.

Distribution
- install.sh (served at logicsrc.com/install.sh) installs the CLI from the
  GitHub repo: tarball -> npm install -> `npm run build:cli` -> logicsrc wrapper.
- root build:cli builds only the CLI's workspace chain (skips web/api/next).

Cleanup
- removed the commandboard-api credshare backend (superseded by the PWA) and its
  Supabase/Turso stores + libsql dep; commandboard-api tests green (40).
- removed the Next.js /teams page (the PWA is the web UI now).

Verified end-to-end: two accounts register on the PWA, mint lsk_ keys, CLI login
uploads identity keys, owner pushes an encrypted .env, teammate invited ->
accepted -> granted -> pulls the exact file. Server stores ciphertext only.
Full workspace build + tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:29:29 +00:00

86 lines
3.4 KiB
JavaScript

// Cookie sessions + auth middleware + CSRF (double-submit).
import { get, run } from "../db.mjs";
import { id, token, sign, unsign } from "./crypto.mjs";
import { config } from "../config.mjs";
const COOKIE = "mc_sess";
const CSRF = "mc_csrf";
const TTL = 1000 * 60 * 60 * 24 * 30; // 30 days
function cookieOpts(extra = {}) {
return { httpOnly: true, sameSite: "lax", secure: config.secure, path: "/", ...extra };
}
export async function createSession(res, userId) {
const t = token();
const now = Date.now();
await run(`INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?,?,?,?)`,
[t, userId, now, now + TTL]);
res.cookie(COOKIE, t, cookieOpts({ maxAge: TTL }));
}
export async function destroySession(req, res) {
const t = req.cookies?.[COOKIE];
if (t) await run(`DELETE FROM sessions WHERE token = ?`, [t]);
res.clearCookie(COOKIE, cookieOpts());
}
// Attach req.user (or null) from the session cookie, and ensure a CSRF token.
export async function sessionMiddleware(req, res, next) {
req.user = null;
const t = req.cookies?.[COOKIE];
if (t) {
const row = await get(
`SELECT u.* FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ?`,
[t, Date.now()]
);
if (row) req.user = row;
else res.clearCookie(COOKIE, cookieOpts());
}
// double-submit CSRF token
let csrf = req.cookies?.[CSRF];
if (!csrf) { csrf = token(16); res.cookie(CSRF, csrf, cookieOpts({ httpOnly: false, maxAge: TTL })); }
req.csrfToken = csrf;
next();
}
export function requireAuth(req, res, next) {
if (!req.user) { setNext(res, req.originalUrl); return res.redirect("/"); }
next();
}
// Remember where to go after login (safe local paths only), across any auth method.
export function setNext(res, pathname) {
if (typeof pathname === "string" && pathname.startsWith("/") && !pathname.startsWith("//")) {
res.cookie("mc_next", sign(pathname), cookieOpts({ maxAge: 1000 * 60 * 10 }));
}
}
export function takeNext(req, res) {
const p = unsign(req.cookies?.mc_next);
if (req.cookies?.mc_next) res.clearCookie("mc_next", cookieOpts());
return typeof p === "string" && p.startsWith("/") && !p.startsWith("//") ? p : null;
}
// CSRF guard for unsafe methods on browser (form) routes. API/webhooks are Bearer/HMAC.
export function csrfGuard(req, res, next) {
if (["GET", "HEAD", "OPTIONS"].includes(req.method)) return next();
// machine endpoints are Bearer/HMAC/PKCE-authenticated, not cookie sessions
if (req.path.startsWith("/api/") || req.path.startsWith("/webhooks/") ||
req.path === "/cli/token" || req.path.startsWith("/cli/device/")) return next();
const sent = req.body?._csrf || req.get("x-csrf-token");
if (!sent || sent !== req.cookies?.[CSRF]) return res.status(403).send("bad csrf token");
next();
}
export const csrfInput = (req) => `<input type="hidden" name="_csrf" value="${req.csrfToken}">`;
// ---- ephemeral auth-ceremony state (webauthn challenge / oauth pkce) in signed cookies ----
export function setCeremony(res, name, value, ttlMs = 1000 * 60 * 5) {
res.cookie(`mc_c_${name}`, sign({ v: value, exp: Date.now() + ttlMs }), cookieOpts({ maxAge: ttlMs }));
}
export function getCeremony(req, name) {
const data = unsign(req.cookies?.[`mc_c_${name}`]);
if (!data || data.exp < Date.now()) return null;
return data.v;
}
export function clearCeremony(res, name) { res.clearCookie(`mc_c_${name}`, cookieOpts()); }