buyer: single-operator password gate (BUYER_PASSWORD + signed session cookie)
Adds an Express requireAuth middleware on /v1/* + the SPA shell and a
Vite/React login screen so anyone who reaches mp-buyers.* must authenticate
before driving the operator's wallet via /v1/submit-prompt or /v1/accept.
Why: the buyer-app sits on a public HTTP port and signs txs with a
server-held private key. Until now the only gate was network reachability.
Mechanics:
- BUYER_PASSWORD + SESSION_SECRET (>=32 chars) loaded by config.ts and
surfaced as createApp() deps. Production refuses to start without both.
- buyer/src/auth.ts: HMAC-SHA256 signed buyer_session cookie (HttpOnly,
SameSite=Lax, Path=/, optional Secure), 7-day rolling expiry,
timing-safe password compare, in-memory 5/15-min per-IP rate limiter.
- /v1/auth/{login,logout,whoami}. Login is rate-limited; 6th wrong
attempt returns 429 + Retry-After. Success resets the bucket.
- SPA boot block (__BUYER_BOOT__ injected by server.ts) is gated by the
same cookie so the buyer's address never reaches an unauthenticated
browser.
- New <AuthProvider>/<RequireAuth> in buyer/src/ui/state/AuthContext.tsx
probes /v1/auth/whoami on mount and a scoped window.fetch patch drops
to the login screen on any mid-session 401 from /v1/* (excluding
/v1/auth/* so wrong-password 401s stay local to <Login>).
- Tailwind login card at buyer/src/ui/pages/Login.tsx. App.tsx wraps in
<RequireAuth> and adds a "Sign out" button in the nav.
Tests: tests/unit/buyer-server-auth.test.ts covers sign/verify, rate
limiter, cookie helpers, login flow (400/401/204/429), whoami
(no-cookie/valid/tampered/expired), rolling renewal, logout, gated
/v1/*, /healthz still open, and the no-auth-deps construction shape.
Updated buyer-server-config + buyer-ui-smoke for the new required env
vars and the AuthProvider wrap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
228
buyer/src/auth.ts
Normal file
228
buyer/src/auth.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* buyer/src/auth.ts — single-operator login primitives.
|
||||
*
|
||||
* The buyer-app sits on a public HTTP port and signs txs with a server-held
|
||||
* private key. Without a gate, anyone who reaches the port can drain the
|
||||
* operator's wallet via /v1/submit-prompt + /v1/accept. This module provides
|
||||
* the pieces an Express middleware needs to enforce a password-only gate:
|
||||
*
|
||||
* - signSession / verifySession — HMAC-SHA256 signed cookies carrying
|
||||
* { iat, exp }. No DB; revocation = wait for expiry or rotate SESSION_SECRET.
|
||||
* - timingSafeCompareStrings — constant-time string equality for the
|
||||
* password check and cookie signature compare.
|
||||
* - createRateLimiter — in-memory per-IP brute-force limiter; resets on
|
||||
* successful login or process restart.
|
||||
* - SESSION_COOKIE_NAME / SESSION_MAX_AGE_MS / buildSessionCookieHeader —
|
||||
* cookie header helpers shared by login, logout, and the rolling
|
||||
* refresh in requireAuth.
|
||||
*
|
||||
* Pure (no Express imports). All time inputs are injectable for tests.
|
||||
*/
|
||||
|
||||
import { createHmac, timingSafeEqual } from "crypto";
|
||||
|
||||
export const SESSION_COOKIE_NAME = "buyer_session";
|
||||
export const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
export interface SessionPayload {
|
||||
/** issued-at, ms since epoch */
|
||||
iat: number;
|
||||
/** expires-at, ms since epoch */
|
||||
exp: number;
|
||||
}
|
||||
|
||||
function base64UrlEncode(input: Buffer | string): string {
|
||||
const buf = typeof input === "string" ? Buffer.from(input, "utf8") : input;
|
||||
return buf.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function base64UrlDecode(input: string): Buffer | null {
|
||||
// Reject non-base64url chars rather than silently decoding garbage.
|
||||
if (!/^[A-Za-z0-9_-]*$/.test(input)) return null;
|
||||
const pad = input.length % 4 === 0 ? "" : "=".repeat(4 - (input.length % 4));
|
||||
try {
|
||||
return Buffer.from(input.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hmac(secret: string, msg: string): Buffer {
|
||||
return createHmac("sha256", secret).update(msg).digest();
|
||||
}
|
||||
|
||||
/** Sign a session payload as `<payloadB64>.<sigB64>`. */
|
||||
export function signSession(secret: string, payload: SessionPayload): string {
|
||||
const payloadB64 = base64UrlEncode(JSON.stringify(payload));
|
||||
const sigB64 = base64UrlEncode(hmac(secret, payloadB64));
|
||||
return `${payloadB64}.${sigB64}`;
|
||||
}
|
||||
|
||||
export interface VerifyResult {
|
||||
valid: boolean;
|
||||
payload?: SessionPayload;
|
||||
reason?: "format" | "signature" | "expired";
|
||||
}
|
||||
|
||||
/** Verify a `<payloadB64>.<sigB64>` string. */
|
||||
export function verifySession(secret: string, raw: string, nowMs: number): VerifyResult {
|
||||
if (typeof raw !== "string" || raw.length === 0) {
|
||||
return { valid: false, reason: "format" };
|
||||
}
|
||||
const dot = raw.indexOf(".");
|
||||
if (dot <= 0 || dot === raw.length - 1) {
|
||||
return { valid: false, reason: "format" };
|
||||
}
|
||||
const payloadB64 = raw.slice(0, dot);
|
||||
const sigB64 = raw.slice(dot + 1);
|
||||
const expected = base64UrlEncode(hmac(secret, payloadB64));
|
||||
if (!timingSafeCompareStrings(sigB64, expected)) {
|
||||
return { valid: false, reason: "signature" };
|
||||
}
|
||||
const payloadJson = base64UrlDecode(payloadB64);
|
||||
if (payloadJson === null) return { valid: false, reason: "format" };
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(payloadJson.toString("utf8"));
|
||||
} catch {
|
||||
return { valid: false, reason: "format" };
|
||||
}
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
typeof (parsed as { iat?: unknown }).iat !== "number" ||
|
||||
typeof (parsed as { exp?: unknown }).exp !== "number"
|
||||
) {
|
||||
return { valid: false, reason: "format" };
|
||||
}
|
||||
const payload = parsed as SessionPayload;
|
||||
if (nowMs >= payload.exp) return { valid: false, reason: "expired" };
|
||||
return { valid: true, payload };
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time string compare. Pads both inputs to a common length so the
|
||||
* comparison time doesn't leak the secret's length. Always touches both
|
||||
* strings even when lengths differ.
|
||||
*/
|
||||
export function timingSafeCompareStrings(a: string, b: string): boolean {
|
||||
const aBuf = Buffer.from(a, "utf8");
|
||||
const bBuf = Buffer.from(b, "utf8");
|
||||
const len = Math.max(aBuf.length, bBuf.length);
|
||||
const aPad = Buffer.alloc(len);
|
||||
const bPad = Buffer.alloc(len);
|
||||
aBuf.copy(aPad);
|
||||
bBuf.copy(bPad);
|
||||
const eq = timingSafeEqual(aPad, bPad);
|
||||
return eq && aBuf.length === bBuf.length;
|
||||
}
|
||||
|
||||
// ─── Rate limiter ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface RateLimitDecision {
|
||||
allowed: boolean;
|
||||
/** ms until the bucket resets. 0 when allowed. */
|
||||
retryAfterMs: number;
|
||||
/** Attempts consumed in the current window (including this one when allowed). */
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface RateLimiter {
|
||||
attempt(key: string): RateLimitDecision;
|
||||
reset(key: string): void;
|
||||
/** For tests: clear all buckets. */
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
export interface RateLimiterOpts {
|
||||
max: number;
|
||||
windowMs: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
interface Bucket {
|
||||
count: number;
|
||||
windowStart: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sliding-window rate limiter. Keyed by arbitrary string (typically req.ip).
|
||||
* Counts every call; on overflow returns `allowed=false` and the ms remaining
|
||||
* in the current window. `reset(key)` is called on successful login so a
|
||||
* legitimate operator who typo'd a few times isn't locked out.
|
||||
*/
|
||||
export function createRateLimiter(opts: RateLimiterOpts): RateLimiter {
|
||||
const buckets = new Map<string, Bucket>();
|
||||
const now = opts.now ?? (() => Date.now());
|
||||
|
||||
return {
|
||||
attempt(key: string): RateLimitDecision {
|
||||
const t = now();
|
||||
const existing = buckets.get(key);
|
||||
if (!existing || t - existing.windowStart >= opts.windowMs) {
|
||||
const fresh: Bucket = { count: 1, windowStart: t };
|
||||
buckets.set(key, fresh);
|
||||
return { allowed: true, retryAfterMs: 0, count: 1 };
|
||||
}
|
||||
if (existing.count >= opts.max) {
|
||||
const retryAfterMs = Math.max(0, opts.windowMs - (t - existing.windowStart));
|
||||
return { allowed: false, retryAfterMs, count: existing.count };
|
||||
}
|
||||
existing.count += 1;
|
||||
return { allowed: true, retryAfterMs: 0, count: existing.count };
|
||||
},
|
||||
reset(key: string): void {
|
||||
buckets.delete(key);
|
||||
},
|
||||
clear(): void {
|
||||
buckets.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Cookie header helpers ────────────────────────────────────────────────
|
||||
|
||||
export interface CookieHeaderOpts {
|
||||
value: string;
|
||||
maxAgeMs: number;
|
||||
secure: boolean;
|
||||
}
|
||||
|
||||
/** Build a Set-Cookie value for the session cookie. Path=/, HttpOnly, SameSite=Lax. */
|
||||
export function buildSessionCookieHeader(opts: CookieHeaderOpts): string {
|
||||
const parts = [
|
||||
`${SESSION_COOKIE_NAME}=${opts.value}`,
|
||||
`Max-Age=${Math.max(0, Math.floor(opts.maxAgeMs / 1000))}`,
|
||||
"Path=/",
|
||||
"HttpOnly",
|
||||
"SameSite=Lax",
|
||||
];
|
||||
if (opts.secure) parts.push("Secure");
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
/** Build a Set-Cookie value that clears the session cookie. */
|
||||
export function buildClearCookieHeader(secure: boolean): string {
|
||||
return buildSessionCookieHeader({ value: "", maxAgeMs: 0, secure });
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single cookie value out of a `Cookie:` request header.
|
||||
* Returns null when the header is missing or the named cookie isn't present.
|
||||
* Tolerates leading whitespace and the standard `; `-separated format.
|
||||
*/
|
||||
export function getCookie(cookieHeader: string | undefined, name: string): string | null {
|
||||
if (!cookieHeader) return null;
|
||||
for (const part of cookieHeader.split(";")) {
|
||||
const eq = part.indexOf("=");
|
||||
if (eq === -1) continue;
|
||||
const k = part.slice(0, eq).trim();
|
||||
if (k === name) {
|
||||
return part.slice(eq + 1).trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -4,6 +4,12 @@
|
||||
* Required env vars:
|
||||
* BUYER_PRIV_KEY_HEX — 32-byte (64-char) hex Ed25519 private key
|
||||
* INDEXER_URL — base URL of the indexer (e.g. http://localhost:3001)
|
||||
* BUYER_PASSWORD — plaintext password for the operator login screen.
|
||||
* Single-operator gate: anyone who knows this password
|
||||
* can drive the buyer (spend funds via /v1/submit-prompt
|
||||
* etc). Treat with the same care as BUYER_PRIV_KEY_HEX.
|
||||
* SESSION_SECRET — HMAC key for signing the buyer_session cookie.
|
||||
* Must be ≥ 32 chars. Generate with `openssl rand -hex 32`.
|
||||
*
|
||||
* Optional:
|
||||
* BUYER_PORT — port to listen on (default 3002)
|
||||
@@ -11,6 +17,9 @@
|
||||
* OGMIOS_URL — Ogmios HTTP endpoint (required iff LIVE_CHAIN=1)
|
||||
* LIVE_CHAIN — "1" opts in to LiveOgmiosProvider (real submitTx).
|
||||
* Default off → ReadOnlyOgmiosProvider (safe).
|
||||
* COOKIE_SECURE — "1" (default) sets Secure on the session cookie so
|
||||
* it's only sent over HTTPS. Set "0" for plain-HTTP
|
||||
* local dev over loopback.
|
||||
* TTS_PIPER_BASE_URL — Base URL of the openedai-speech-min PiperTTS host.
|
||||
* When unset, /v1/synth-speech responds 503 (the
|
||||
* capability stays disabled — the rest of the
|
||||
@@ -24,6 +33,7 @@
|
||||
|
||||
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
|
||||
const POS_INT_RE = /^[1-9]\d*$/;
|
||||
const SESSION_SECRET_MIN_LEN = 32;
|
||||
|
||||
export interface BuyerConfig {
|
||||
privKeyHex: string;
|
||||
@@ -34,6 +44,9 @@ export interface BuyerConfig {
|
||||
liveChain: boolean;
|
||||
ttsPiperBaseUrl: string;
|
||||
archiveDir: string;
|
||||
password: string;
|
||||
sessionSecret: string;
|
||||
cookieSecure: boolean;
|
||||
}
|
||||
|
||||
function requireField(env: Record<string, string | undefined>, name: string): string {
|
||||
@@ -92,5 +105,38 @@ export function loadConfig(env: Record<string, string | undefined>): BuyerConfig
|
||||
// bind-mounted /repo/data/archive.
|
||||
const archiveDir = env.ARCHIVE_DIR ?? "./data/archive";
|
||||
|
||||
return { privKeyHex, indexerUrl, port, networkId, ogmiosUrl, liveChain, ttsPiperBaseUrl, archiveDir };
|
||||
const password = requireField(env, "BUYER_PASSWORD");
|
||||
const sessionSecret = requireField(env, "SESSION_SECRET");
|
||||
if (sessionSecret.length < SESSION_SECRET_MIN_LEN) {
|
||||
throw new Error(
|
||||
`loadConfig: SESSION_SECRET must be at least ${SESSION_SECRET_MIN_LEN} chars (generate with: openssl rand -hex 32)`,
|
||||
);
|
||||
}
|
||||
|
||||
// COOKIE_SECURE: defaults to true. Operators running the buyer-app behind
|
||||
// a plain-HTTP loopback for local dev set "0" so the cookie still rides
|
||||
// along (browsers drop Secure cookies on http://). Accept only "0" / "1"
|
||||
// to keep boot-time misconfig loud.
|
||||
let cookieSecure = true;
|
||||
const cookieSecureStr = env.COOKIE_SECURE;
|
||||
if (cookieSecureStr !== undefined && cookieSecureStr !== "") {
|
||||
if (cookieSecureStr !== "0" && cookieSecureStr !== "1") {
|
||||
throw new Error('loadConfig: COOKIE_SECURE must be "0" or "1"');
|
||||
}
|
||||
cookieSecure = cookieSecureStr === "1";
|
||||
}
|
||||
|
||||
return {
|
||||
privKeyHex,
|
||||
indexerUrl,
|
||||
port,
|
||||
networkId,
|
||||
ogmiosUrl,
|
||||
liveChain,
|
||||
ttsPiperBaseUrl,
|
||||
archiveDir,
|
||||
password,
|
||||
sessionSecret,
|
||||
cookieSecure,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -99,6 +99,9 @@ export async function runMain(env: Record<string, string | undefined>): Promise<
|
||||
marketplace,
|
||||
ttsPiperBaseUrl: config.ttsPiperBaseUrl,
|
||||
archive,
|
||||
password: config.password,
|
||||
sessionSecret: config.sessionSecret,
|
||||
cookieSecure: config.cookieSecure,
|
||||
});
|
||||
|
||||
const server = app.listen(config.port, () => {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* through the buyer-app keeps the buyer_pkh out of the page bundle.
|
||||
*/
|
||||
|
||||
import express, { type Express, type Request, type Response } from "express";
|
||||
import express, { type Express, type NextFunction, type Request, type Response } from "express";
|
||||
import { existsSync, readFileSync, statSync } from "fs";
|
||||
import { bech32 } from "bech32";
|
||||
import type { ChainProvider } from "@marketplace/shared/chain";
|
||||
@@ -28,6 +28,18 @@ import type { ChatMessage } from "@marketplace/shared/tx";
|
||||
import { canonicalize } from "@marketplace/shared/cbor";
|
||||
import { runAccept } from "./cli/acceptFlow.js";
|
||||
import type { ResponseArchive } from "./db/archive.js";
|
||||
import {
|
||||
SESSION_COOKIE_NAME,
|
||||
SESSION_MAX_AGE_MS,
|
||||
buildClearCookieHeader,
|
||||
buildSessionCookieHeader,
|
||||
createRateLimiter,
|
||||
getCookie,
|
||||
signSession,
|
||||
timingSafeCompareStrings,
|
||||
verifySession,
|
||||
type RateLimiter,
|
||||
} from "./auth.js";
|
||||
|
||||
const ESCROW_REF_RE = /^([0-9a-f]{64})#(\d+)$/;
|
||||
|
||||
@@ -74,6 +86,19 @@ export interface AppDeps {
|
||||
archive?: ResponseArchive;
|
||||
/** Injectable fetch for tests. Defaults to globalThis.fetch. */
|
||||
fetchImpl?: typeof globalThis.fetch;
|
||||
/** Operator login password (BUYER_PASSWORD). When missing, all /v1/*
|
||||
* non-auth routes and the SPA shell respond 503 auth_unconfigured. */
|
||||
password?: string;
|
||||
/** HMAC key for the buyer_session cookie (SESSION_SECRET). When missing,
|
||||
* auth is treated as unconfigured (same 503 behavior as a missing password). */
|
||||
sessionSecret?: string;
|
||||
/** When true, the session cookie carries the Secure attribute. Defaults
|
||||
* to true; tests and local-loopback dev override to false. */
|
||||
cookieSecure?: boolean;
|
||||
/** Injectable clock for cookie issued-at / expiry calculations and the
|
||||
* brute-force rate-limit window. Defaults to Date.now. Tests use a fake
|
||||
* clock to drive expiry and rolling-renewal assertions deterministically. */
|
||||
nowMs?: () => number;
|
||||
}
|
||||
|
||||
interface ResolvedDeps {
|
||||
@@ -125,6 +150,134 @@ export function createApp(deps: AppDeps): Express {
|
||||
res.status(200).json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Auth wiring ───────────────────────────────────────────────────────
|
||||
// The single-operator login gate. When `password` and `sessionSecret` are
|
||||
// both configured, every /v1/* route below (other than /v1/auth/*) and the
|
||||
// SPA boot block require a valid buyer_session cookie.
|
||||
//
|
||||
// When either is missing, `authReady` is false and the gate is bypassed
|
||||
// entirely (no /v1/auth/* routes mounted; no requireAuth middleware). This
|
||||
// is purely a test-construction shape — the production boot path
|
||||
// (runMain → loadConfig) refuses to start without both env vars, so a
|
||||
// deployment with `authReady === false` is impossible.
|
||||
const authReady =
|
||||
typeof deps.password === "string" && deps.password.length > 0 &&
|
||||
typeof deps.sessionSecret === "string" && deps.sessionSecret.length > 0;
|
||||
const cookieSecure = deps.cookieSecure !== false; // default true
|
||||
const nowMs = deps.nowMs ?? (() => Date.now());
|
||||
|
||||
// Brute-force limiter: 5 attempts per 15 minutes per IP. In-memory only;
|
||||
// resets on a successful login (legitimate operator who typo'd) and on
|
||||
// process restart. For a single-operator service this is the right trade.
|
||||
const loginLimiter: RateLimiter = createRateLimiter({
|
||||
max: 5,
|
||||
windowMs: 15 * 60 * 1000,
|
||||
now: nowMs,
|
||||
});
|
||||
|
||||
function clientIp(req: Request): string {
|
||||
// Default Express req.ip honors `trust proxy`; we deliberately don't
|
||||
// enable trust proxy here, so this is the direct socket peer. Operators
|
||||
// behind a reverse proxy that masks the client should configure
|
||||
// trust proxy on the outer Express; for our brute-force gate, even the
|
||||
// proxy IP is a usable bucket key (it just rate-limits the proxy itself).
|
||||
return req.ip ?? "unknown";
|
||||
}
|
||||
|
||||
function issueSessionCookie(res: Response): void {
|
||||
if (!authReady) return;
|
||||
const iat = nowMs();
|
||||
const exp = iat + SESSION_MAX_AGE_MS;
|
||||
const token = signSession(deps.sessionSecret!, { iat, exp });
|
||||
res.setHeader(
|
||||
"Set-Cookie",
|
||||
buildSessionCookieHeader({ value: token, maxAgeMs: SESSION_MAX_AGE_MS, secure: cookieSecure }),
|
||||
);
|
||||
}
|
||||
|
||||
if (authReady) {
|
||||
/**
|
||||
* Express middleware that requires a valid buyer_session cookie. On
|
||||
* success, refreshes the cookie (rolling SESSION_MAX_AGE_MS expiry).
|
||||
* On failure responds 401 unauthenticated for /v1/* — the SPA shell
|
||||
* route lower in the file separately handles the unauth HTML case
|
||||
* by serving the bundle without the boot block.
|
||||
*/
|
||||
const requireAuth = (req: Request, res: Response, next: NextFunction): void => {
|
||||
const raw = getCookie(req.headers.cookie, SESSION_COOKIE_NAME);
|
||||
if (raw === null) {
|
||||
jsonError(res, 401, "unauthenticated", "no session cookie");
|
||||
return;
|
||||
}
|
||||
const result = verifySession(deps.sessionSecret!, raw, nowMs());
|
||||
if (!result.valid) {
|
||||
jsonError(res, 401, "unauthenticated", `session ${result.reason ?? "invalid"}`);
|
||||
return;
|
||||
}
|
||||
// Rolling renewal — every authenticated request pushes exp out another
|
||||
// SESSION_MAX_AGE_MS. Keeps active operators logged in without ever
|
||||
// accumulating long-lived tokens that linger past inactivity.
|
||||
issueSessionCookie(res);
|
||||
next();
|
||||
};
|
||||
|
||||
// /v1/auth/* — login, logout, whoami. Mounted before any protected
|
||||
// /v1/* routes so requireAuth doesn't gate login itself. /v1/auth/whoami
|
||||
// is intentionally protected — the SPA's AuthProvider uses it as a
|
||||
// "do I have a valid session?" probe.
|
||||
app.post("/v1/auth/login", (req: Request, res: Response) => {
|
||||
const ip = clientIp(req);
|
||||
const limit = loginLimiter.attempt(ip);
|
||||
if (!limit.allowed) {
|
||||
const retryAfterSec = Math.ceil(limit.retryAfterMs / 1000);
|
||||
res.setHeader("Retry-After", String(retryAfterSec));
|
||||
jsonError(
|
||||
res,
|
||||
429,
|
||||
"rate_limited",
|
||||
`too many login attempts; try again in ${retryAfterSec}s`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const body = readBody(req.body);
|
||||
const password = body.password;
|
||||
if (typeof password !== "string" || password.length === 0) {
|
||||
jsonError(res, 400, "password_required", 'body must include { "password": "<string>" }');
|
||||
return;
|
||||
}
|
||||
if (!timingSafeCompareStrings(password, deps.password!)) {
|
||||
jsonError(res, 401, "invalid_password", "incorrect password");
|
||||
return;
|
||||
}
|
||||
loginLimiter.reset(ip);
|
||||
issueSessionCookie(res);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
app.post("/v1/auth/logout", (_req: Request, res: Response) => {
|
||||
// Idempotent — safe to call without an existing session. We always
|
||||
// set the clear cookie header so any stale cookie in the browser is
|
||||
// overwritten regardless of whether it was valid.
|
||||
res.setHeader("Set-Cookie", buildClearCookieHeader(cookieSecure));
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
app.get("/v1/auth/whoami", requireAuth, (_req: Request, res: Response) => {
|
||||
res.status(200).json({ authenticated: true });
|
||||
});
|
||||
|
||||
// Every other /v1/* route is protected. Mount the gate as a path-prefix
|
||||
// middleware that skips /v1/auth/* (already mounted above) but applies
|
||||
// to everything else under /v1/.
|
||||
app.use("/v1", (req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.path.startsWith("/auth/")) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
requireAuth(req, res, next);
|
||||
});
|
||||
}
|
||||
|
||||
// chain/walletKey/indexerUrl are required for /v1/* routes; if any is
|
||||
// missing we still mount the static bundle but the API endpoints respond
|
||||
// 503 service_unavailable so the SPA can render a clear "boot misconfigured"
|
||||
@@ -807,6 +960,11 @@ export function createApp(deps: AppDeps): Express {
|
||||
// can show the user their identity without the server leaking the
|
||||
// private key into the page bundle. The SPA talks to /v1/* on this
|
||||
// origin (relative URLs), so no indexer/ogmios endpoints leak either.
|
||||
//
|
||||
// The boot block is ALSO gated by auth: unauthenticated requests get
|
||||
// the same HTML shell but with NO boot script, so the SPA's
|
||||
// AuthProvider renders <Login> and the buyer's address never reaches
|
||||
// an unauthenticated browser.
|
||||
const indexPath = `${deps.distPath}/index.html`;
|
||||
const cachedTemplate = existsSync(indexPath)
|
||||
? readFileSync(indexPath, "utf8")
|
||||
@@ -825,17 +983,28 @@ export function createApp(deps: AppDeps): Express {
|
||||
},
|
||||
})};</script>`
|
||||
: "";
|
||||
const indexBody = cachedTemplate.replace(
|
||||
const indexWithBoot = cachedTemplate.replace(
|
||||
"</head>",
|
||||
`${bootScript}\n </head>`,
|
||||
);
|
||||
// Anonymous shell: same HTML template but no boot block. The SPA's
|
||||
// AuthProvider handles the rest.
|
||||
const indexAnonymous = cachedTemplate;
|
||||
|
||||
app.get(/^\/(?!healthz|v1\/).*/, (_req: Request, res: Response) => {
|
||||
if (!indexBody) {
|
||||
function isAuthenticatedHtmlRequest(req: Request): boolean {
|
||||
if (!authReady) return true; // auth disabled (test mode): no gating
|
||||
const raw = getCookie(req.headers.cookie, SESSION_COOKIE_NAME);
|
||||
if (raw === null) return false;
|
||||
return verifySession(deps.sessionSecret!, raw, nowMs()).valid;
|
||||
}
|
||||
|
||||
app.get(/^\/(?!healthz|v1\/).*/, (req: Request, res: Response) => {
|
||||
if (!cachedTemplate) {
|
||||
return res.status(404).json({ error: "not_found" });
|
||||
}
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
return res.send(indexBody);
|
||||
const body = isAuthenticatedHtmlRequest(req) ? indexWithBoot : indexAnonymous;
|
||||
return res.send(body);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* buyer/src/ui/App.tsx — router shell.
|
||||
*
|
||||
* Routes:
|
||||
* Routes (all gated by <RequireAuth>):
|
||||
* / → <Dashboard />
|
||||
* /tasks → <TaskHistory />
|
||||
* /pending → <PendingReceipts /> UX-1: buyer-side Accept button
|
||||
@@ -13,24 +13,41 @@ import Dashboard from "./pages/Dashboard.js";
|
||||
import TaskHistory from "./pages/TaskHistory.js";
|
||||
import Wallet from "./pages/Wallet.js";
|
||||
import PendingReceipts from "./pages/PendingReceipts.js";
|
||||
import { RequireAuth, useAuth } from "./state/AuthContext.js";
|
||||
|
||||
function SignOutButton() {
|
||||
const { signOut } = useAuth();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void signOut(); }}
|
||||
className="ml-auto text-sm text-gray-600 hover:text-gray-900 hover:underline"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 text-gray-900">
|
||||
<nav className="bg-white border-b border-gray-200 px-6 py-3 flex gap-6">
|
||||
<Link to="/" className="font-medium hover:underline">Dashboard</Link>
|
||||
<Link to="/tasks" className="font-medium hover:underline">Tasks</Link>
|
||||
<Link to="/pending" className="font-medium hover:underline">Pending</Link>
|
||||
<Link to="/wallet" className="font-medium hover:underline">Wallet</Link>
|
||||
</nav>
|
||||
<main className="p-6">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/tasks" element={<TaskHistory />} />
|
||||
<Route path="/pending" element={<PendingReceipts />} />
|
||||
<Route path="/wallet" element={<Wallet />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
<RequireAuth>
|
||||
<div className="min-h-screen bg-gray-50 text-gray-900">
|
||||
<nav className="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-6">
|
||||
<Link to="/" className="font-medium hover:underline">Dashboard</Link>
|
||||
<Link to="/tasks" className="font-medium hover:underline">Tasks</Link>
|
||||
<Link to="/pending" className="font-medium hover:underline">Pending</Link>
|
||||
<Link to="/wallet" className="font-medium hover:underline">Wallet</Link>
|
||||
<SignOutButton />
|
||||
</nav>
|
||||
<main className="p-6">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/tasks" element={<TaskHistory />} />
|
||||
<Route path="/pending" element={<PendingReceipts />} />
|
||||
<Route path="/wallet" element={<Wallet />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</RequireAuth>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App.js";
|
||||
import { MarketplaceProvider } from "./state/MarketplaceContext.js";
|
||||
import { AuthProvider } from "./state/AuthContext.js";
|
||||
// MemoryTaskHistoryStore is a no-op placeholder for the browser-side SDK
|
||||
// instance — submitPrompt now runs server-side, so the browser never
|
||||
// records anything to a history store. The /tasks page reads directly
|
||||
@@ -77,9 +78,11 @@ if (!rootEl) throw new Error("[buyer] #root element not found");
|
||||
createRoot(rootEl).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<MarketplaceProvider marketplace={marketplace}>
|
||||
<App />
|
||||
</MarketplaceProvider>
|
||||
<AuthProvider>
|
||||
<MarketplaceProvider marketplace={marketplace}>
|
||||
<App />
|
||||
</MarketplaceProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
107
buyer/src/ui/pages/Login.tsx
Normal file
107
buyer/src/ui/pages/Login.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* buyer/src/ui/pages/Login.tsx — operator password gate.
|
||||
*
|
||||
* Posts { password } to /v1/auth/login. On 204, calls auth.refresh() which
|
||||
* flips the AuthContext to "authenticated" and the surrounding <RequireAuth>
|
||||
* unmounts this page and mounts the real app. On 401 → inline "incorrect
|
||||
* password" error. On 429 → reads the Retry-After header and surfaces the
|
||||
* remaining seconds.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, type FormEvent } from "react";
|
||||
import { useAuth } from "../state/AuthContext.js";
|
||||
|
||||
export default function Login() {
|
||||
const { refresh } = useAuth();
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (submitting) return;
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch("/v1/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (res.status === 204) {
|
||||
setPassword("");
|
||||
await refresh();
|
||||
return;
|
||||
}
|
||||
if (res.status === 429) {
|
||||
const retryAfter = res.headers.get("Retry-After");
|
||||
const secs = retryAfter ? Number(retryAfter) : NaN;
|
||||
setError(
|
||||
Number.isFinite(secs) && secs > 0
|
||||
? `Too many attempts. Try again in ${secs}s.`
|
||||
: "Too many attempts. Try again later.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (res.status === 401) {
|
||||
setError("Incorrect password.");
|
||||
return;
|
||||
}
|
||||
if (res.status === 503) {
|
||||
setError("Login is not configured on this server. Check BUYER_PASSWORD/SESSION_SECRET.");
|
||||
return;
|
||||
}
|
||||
setError(`Unexpected response (${res.status}). Try again.`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="w-full max-w-sm bg-white rounded-lg shadow border border-gray-200 p-6 space-y-4"
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-gray-900">Buyer sign in</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Enter the operator password to access this buyer instance.
|
||||
</p>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-gray-700">Password</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={submitting}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-gray-500 focus:outline-none focus:ring-1 focus:ring-gray-500 disabled:bg-gray-100"
|
||||
/>
|
||||
</label>
|
||||
{error !== null ? (
|
||||
<div role="alert" className="text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || password.length === 0}
|
||||
className="w-full inline-flex justify-center items-center rounded-md bg-gray-900 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-gray-800 disabled:bg-gray-400"
|
||||
>
|
||||
{submitting ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
buyer/src/ui/state/AuthContext.tsx
Normal file
145
buyer/src/ui/state/AuthContext.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* buyer/src/ui/state/AuthContext.tsx — React context for the operator login gate.
|
||||
*
|
||||
* On mount, probes /v1/auth/whoami to learn whether the existing buyer_session
|
||||
* cookie is valid. Exposes:
|
||||
*
|
||||
* - status: "checking" | "authenticated" | "unauthenticated"
|
||||
* - refresh: re-probes /v1/auth/whoami (call after a successful login POST)
|
||||
* - signOut: POSTs /v1/auth/logout, clears the cookie, drops to login screen
|
||||
*
|
||||
* <RequireAuth>{children}</RequireAuth> is a convenience wrapper used by App.tsx:
|
||||
* - "checking": renders a minimal blank loader (avoids the login flash)
|
||||
* - "unauthenticated": renders <Login/>
|
||||
* - "authenticated": renders children
|
||||
*/
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import Login from "../pages/Login.js";
|
||||
|
||||
export type AuthStatus = "checking" | "authenticated" | "unauthenticated";
|
||||
|
||||
export interface AuthContextValue {
|
||||
status: AuthStatus;
|
||||
refresh: () => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const v = useContext(AuthContext);
|
||||
if (!v) {
|
||||
throw new Error("useAuth: no AuthContext in tree — wrap app in <AuthProvider>");
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
async function probeWhoami(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch("/v1/auth/whoami", {
|
||||
method: "GET",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Optional initial status. When set, the provider skips the /v1/auth/whoami
|
||||
* probe and seeds the status directly. Useful for tests and for any future
|
||||
* server-side render path where the auth state is already known at mount.
|
||||
*/
|
||||
initialStatus?: AuthStatus;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children, initialStatus }: AuthProviderProps) {
|
||||
const [status, setStatus] = useState<AuthStatus>(initialStatus ?? "checking");
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const ok = await probeWhoami();
|
||||
setStatus(ok ? "authenticated" : "unauthenticated");
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
try {
|
||||
await fetch("/v1/auth/logout", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
} catch {
|
||||
// best-effort: if the network is down we still want to render the
|
||||
// login screen locally so the operator doesn't keep operating on a
|
||||
// dead session.
|
||||
}
|
||||
setStatus("unauthenticated");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialStatus !== undefined) return; // caller-provided status; don't probe
|
||||
void refresh();
|
||||
}, [refresh, initialStatus]);
|
||||
|
||||
// Listen for 401s from any /v1/* call and drop to the login screen.
|
||||
// We patch window.fetch once on mount and restore on unmount so other
|
||||
// code in the app keeps using the normal global. Scoped to /v1/* paths
|
||||
// so third-party fetches (e.g. asset URLs) don't accidentally trip the
|
||||
// gate. The /v1/auth/login probe itself can return 401 legitimately
|
||||
// (wrong password) — Login.tsx handles that locally, and we only flip
|
||||
// state when an authenticated session expires mid-use (a NON-/auth call
|
||||
// returns 401 while status === "authenticated").
|
||||
useEffect(() => {
|
||||
const original = window.fetch.bind(window);
|
||||
const patched: typeof window.fetch = async (input, init) => {
|
||||
const res = await original(input, init);
|
||||
const url = typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL ? input.toString()
|
||||
: (input as Request).url;
|
||||
if (res.status === 401 && url.includes("/v1/") && !url.includes("/v1/auth/")) {
|
||||
setStatus("unauthenticated");
|
||||
}
|
||||
return res;
|
||||
};
|
||||
window.fetch = patched;
|
||||
return () => {
|
||||
window.fetch = original;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ status, refresh, signOut }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export interface RequireAuthProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function RequireAuth({ children }: RequireAuthProps) {
|
||||
const { status } = useAuth();
|
||||
if (status === "checking") {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center text-gray-400 text-sm">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (status === "unauthenticated") {
|
||||
return <Login />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
Reference in New Issue
Block a user