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:
@@ -15,6 +15,14 @@ BUYER_PRIV_KEY_HEX=PASTE_64_HEX_CHARS_HERE
|
||||
# Indexer base URL. Inside compose this is the service name.
|
||||
INDEXER_URL=http://indexer:8090
|
||||
|
||||
# Single-operator login password. Anyone who knows this can spend funds
|
||||
# via /v1/submit-prompt + /v1/accept, so treat it like BUYER_PRIV_KEY_HEX.
|
||||
BUYER_PASSWORD=CHANGE_ME
|
||||
|
||||
# HMAC key for the buyer_session cookie (HttpOnly, SameSite=Lax, 7-day rolling).
|
||||
# Minimum 32 chars. Generate with: openssl rand -hex 32
|
||||
SESSION_SECRET=PASTE_64_HEX_CHARS_HERE_OR_LONGER
|
||||
|
||||
# ─── Optional ─────────────────────────────────────────────────────────────
|
||||
|
||||
# HTTP listen port (default 3002 in buyer/src/config.ts; we override to
|
||||
@@ -26,6 +34,10 @@ BUYER_PORT=8070
|
||||
# See ARCHITECTURE.md §9 follow-up — naming drift to be reconciled.)
|
||||
NETWORK_ID=0
|
||||
|
||||
# Set "0" for plain-HTTP local dev so the buyer_session cookie still rides
|
||||
# along over loopback. Production behind TLS should leave this unset / "1".
|
||||
# COOKIE_SECURE=1
|
||||
|
||||
# ─── Optional: CIP-33 reference scripts ───────────────────────────────────
|
||||
# When set, buyer-signed script-spending txs (Accept, Reclaim) read the
|
||||
# escrow validator from an on-chain reference UTxO instead of inlining
|
||||
|
||||
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}</>;
|
||||
}
|
||||
327
tests/unit/buyer-server-auth.test.ts
Normal file
327
tests/unit/buyer-server-auth.test.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* buyer-server-auth.test.ts — coverage for the operator login gate.
|
||||
*
|
||||
* Two flavors:
|
||||
* 1. Pure-function tests for buyer/src/auth.ts (sign/verify, rate limit,
|
||||
* cookie header helpers).
|
||||
* 2. Supertest-driven integration over createApp({password, sessionSecret})
|
||||
* covering /v1/auth/{login,logout,whoami}, the /v1/* gate, and rolling
|
||||
* cookie renewal.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../../buyer/src/server.js";
|
||||
import {
|
||||
SESSION_COOKIE_NAME,
|
||||
SESSION_MAX_AGE_MS,
|
||||
buildClearCookieHeader,
|
||||
buildSessionCookieHeader,
|
||||
createRateLimiter,
|
||||
getCookie,
|
||||
signSession,
|
||||
timingSafeCompareStrings,
|
||||
verifySession,
|
||||
} from "../../buyer/src/auth.js";
|
||||
|
||||
// ─── Pure helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("auth: signSession / verifySession", () => {
|
||||
const SECRET = "0".repeat(64);
|
||||
|
||||
it("round-trips a signed session", () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const payload = { iat: now, exp: now + 1000 };
|
||||
const token = signSession(SECRET, payload);
|
||||
const r = verifySession(SECRET, token, now);
|
||||
expect(r.valid).toBe(true);
|
||||
expect(r.payload).toEqual(payload);
|
||||
});
|
||||
|
||||
it("rejects an expired session", () => {
|
||||
const iat = 1_700_000_000_000;
|
||||
const exp = iat + 1000;
|
||||
const token = signSession(SECRET, { iat, exp });
|
||||
const r = verifySession(SECRET, token, exp); // now === exp ⇒ expired
|
||||
expect(r.valid).toBe(false);
|
||||
expect(r.reason).toBe("expired");
|
||||
});
|
||||
|
||||
it("rejects a tampered signature", () => {
|
||||
const iat = 1_700_000_000_000;
|
||||
const token = signSession(SECRET, { iat, exp: iat + 1000 });
|
||||
// Flip the last char of the signature segment.
|
||||
const tampered = token.slice(0, -1) + (token.endsWith("A") ? "B" : "A");
|
||||
const r = verifySession(SECRET, tampered, iat);
|
||||
expect(r.valid).toBe(false);
|
||||
expect(r.reason).toBe("signature");
|
||||
});
|
||||
|
||||
it("rejects a payload that doesn't parse", () => {
|
||||
const r = verifySession(SECRET, "not-a-session", 0);
|
||||
expect(r.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an empty string", () => {
|
||||
const r = verifySession(SECRET, "", 0);
|
||||
expect(r.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth: timingSafeCompareStrings", () => {
|
||||
it("returns true for equal strings", () => {
|
||||
expect(timingSafeCompareStrings("hunter2", "hunter2")).toBe(true);
|
||||
});
|
||||
it("returns false for different strings of equal length", () => {
|
||||
expect(timingSafeCompareStrings("hunter2", "hunter3")).toBe(false);
|
||||
});
|
||||
it("returns false for strings of different lengths", () => {
|
||||
expect(timingSafeCompareStrings("a", "ab")).toBe(false);
|
||||
expect(timingSafeCompareStrings("ab", "a")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth: createRateLimiter", () => {
|
||||
it("allows up to max attempts then denies until window elapses", () => {
|
||||
let t = 0;
|
||||
const limiter = createRateLimiter({ max: 3, windowMs: 1000, now: () => t });
|
||||
expect(limiter.attempt("ip").allowed).toBe(true);
|
||||
expect(limiter.attempt("ip").allowed).toBe(true);
|
||||
expect(limiter.attempt("ip").allowed).toBe(true);
|
||||
const denied = limiter.attempt("ip");
|
||||
expect(denied.allowed).toBe(false);
|
||||
expect(denied.retryAfterMs).toBeGreaterThan(0);
|
||||
// After the window slides past, the bucket resets.
|
||||
t += 1001;
|
||||
expect(limiter.attempt("ip").allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("reset() clears the bucket for a key", () => {
|
||||
let t = 0;
|
||||
const limiter = createRateLimiter({ max: 2, windowMs: 1000, now: () => t });
|
||||
limiter.attempt("a");
|
||||
limiter.attempt("a");
|
||||
expect(limiter.attempt("a").allowed).toBe(false);
|
||||
limiter.reset("a");
|
||||
expect(limiter.attempt("a").allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("buckets are per-key", () => {
|
||||
const limiter = createRateLimiter({ max: 1, windowMs: 1000 });
|
||||
expect(limiter.attempt("a").allowed).toBe(true);
|
||||
expect(limiter.attempt("b").allowed).toBe(true); // different key, fresh
|
||||
expect(limiter.attempt("a").allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth: cookie header helpers", () => {
|
||||
it("buildSessionCookieHeader sets HttpOnly/SameSite=Lax/Path=/", () => {
|
||||
const h = buildSessionCookieHeader({ value: "abc", maxAgeMs: 1000, secure: false });
|
||||
expect(h).toContain(`${SESSION_COOKIE_NAME}=abc`);
|
||||
expect(h).toContain("HttpOnly");
|
||||
expect(h).toContain("SameSite=Lax");
|
||||
expect(h).toContain("Path=/");
|
||||
expect(h).toContain("Max-Age=1");
|
||||
expect(h).not.toContain("Secure");
|
||||
});
|
||||
|
||||
it("buildSessionCookieHeader appends Secure when requested", () => {
|
||||
const h = buildSessionCookieHeader({ value: "abc", maxAgeMs: 1000, secure: true });
|
||||
expect(h).toContain("Secure");
|
||||
});
|
||||
|
||||
it("buildClearCookieHeader produces Max-Age=0", () => {
|
||||
const h = buildClearCookieHeader(false);
|
||||
expect(h).toContain("Max-Age=0");
|
||||
expect(h).toContain(`${SESSION_COOKIE_NAME}=`);
|
||||
});
|
||||
|
||||
it("getCookie parses the named cookie out of a header", () => {
|
||||
expect(getCookie("a=1; buyer_session=xyz; b=2", "buyer_session")).toBe("xyz");
|
||||
expect(getCookie("buyer_session=xyz", "buyer_session")).toBe("xyz");
|
||||
expect(getCookie("a=1; b=2", "buyer_session")).toBeNull();
|
||||
expect(getCookie(undefined, "buyer_session")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Integration via supertest ────────────────────────────────────────────
|
||||
|
||||
const PASSWORD = "hunter2";
|
||||
const SECRET = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
function makeAuthApp(overrides: { nowMs?: () => number } = {}) {
|
||||
return createApp({
|
||||
password: PASSWORD,
|
||||
sessionSecret: SECRET,
|
||||
cookieSecure: false, // tests run over plain HTTP
|
||||
nowMs: overrides.nowMs,
|
||||
});
|
||||
}
|
||||
|
||||
/** Pull the buyer_session value out of a Set-Cookie response header. */
|
||||
function extractSessionCookie(setCookieHeader: string | string[] | undefined): string | null {
|
||||
if (!setCookieHeader) return null;
|
||||
const headers = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
|
||||
for (const h of headers) {
|
||||
const m = /buyer_session=([^;]*)/.exec(h);
|
||||
if (m && m[1].length > 0) return m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("createApp({password, sessionSecret}): /v1/auth/login", () => {
|
||||
it("rejects a missing body field with 400", async () => {
|
||||
const app = makeAuthApp();
|
||||
const res = await request(app).post("/v1/auth/login").send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe("password_required");
|
||||
});
|
||||
|
||||
it("rejects a wrong password with 401", async () => {
|
||||
const app = makeAuthApp();
|
||||
const res = await request(app).post("/v1/auth/login").send({ password: "wrong" });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toBe("invalid_password");
|
||||
});
|
||||
|
||||
it("accepts the right password with 204 + HttpOnly Set-Cookie", async () => {
|
||||
const app = makeAuthApp();
|
||||
const res = await request(app).post("/v1/auth/login").send({ password: PASSWORD });
|
||||
expect(res.status).toBe(204);
|
||||
const setCookie = res.headers["set-cookie"];
|
||||
expect(setCookie).toBeDefined();
|
||||
const cookieHeader = Array.isArray(setCookie) ? setCookie.join(";") : setCookie;
|
||||
expect(cookieHeader).toContain("HttpOnly");
|
||||
expect(cookieHeader).toContain("SameSite=Lax");
|
||||
expect(extractSessionCookie(setCookie)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("rate-limits after 5 wrong attempts with 429 + Retry-After", async () => {
|
||||
const app = makeAuthApp();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const r = await request(app).post("/v1/auth/login").send({ password: "wrong" });
|
||||
expect(r.status).toBe(401);
|
||||
}
|
||||
const sixth = await request(app).post("/v1/auth/login").send({ password: "wrong" });
|
||||
expect(sixth.status).toBe(429);
|
||||
expect(sixth.headers["retry-after"]).toBeDefined();
|
||||
// Even the correct password is rejected while the bucket is full.
|
||||
const seventh = await request(app).post("/v1/auth/login").send({ password: PASSWORD });
|
||||
expect(seventh.status).toBe(429);
|
||||
});
|
||||
|
||||
it("a successful login resets the rate-limit bucket", async () => {
|
||||
const app = makeAuthApp();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await request(app).post("/v1/auth/login").send({ password: "wrong" });
|
||||
}
|
||||
const ok = await request(app).post("/v1/auth/login").send({ password: PASSWORD });
|
||||
expect(ok.status).toBe(204);
|
||||
// After reset, four more wrong attempts shouldn't trip the limit.
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const r = await request(app).post("/v1/auth/login").send({ password: "wrong" });
|
||||
expect(r.status).toBe(401);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createApp: /v1/auth/whoami + protected /v1/* gate", () => {
|
||||
it("whoami without cookie returns 401", async () => {
|
||||
const app = makeAuthApp();
|
||||
const res = await request(app).get("/v1/auth/whoami");
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toBe("unauthenticated");
|
||||
});
|
||||
|
||||
it("whoami with a valid session returns 200 and refreshes the cookie", async () => {
|
||||
const app = makeAuthApp();
|
||||
const login = await request(app).post("/v1/auth/login").send({ password: PASSWORD });
|
||||
const token = extractSessionCookie(login.headers["set-cookie"]);
|
||||
expect(token).not.toBeNull();
|
||||
const res = await request(app)
|
||||
.get("/v1/auth/whoami")
|
||||
.set("Cookie", `${SESSION_COOKIE_NAME}=${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.authenticated).toBe(true);
|
||||
// Rolling renewal: a fresh Set-Cookie should accompany the response.
|
||||
expect(res.headers["set-cookie"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("whoami with a tampered cookie returns 401", async () => {
|
||||
const app = makeAuthApp();
|
||||
const login = await request(app).post("/v1/auth/login").send({ password: PASSWORD });
|
||||
const token = extractSessionCookie(login.headers["set-cookie"])!;
|
||||
const tampered = token.slice(0, -1) + (token.endsWith("A") ? "B" : "A");
|
||||
const res = await request(app)
|
||||
.get("/v1/auth/whoami")
|
||||
.set("Cookie", `${SESSION_COOKIE_NAME}=${tampered}`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("whoami with an expired cookie returns 401", async () => {
|
||||
// Issue a cookie at t=0, then probe at t=SESSION_MAX_AGE_MS+1 to exceed exp.
|
||||
const start = 1_700_000_000_000;
|
||||
let now = start;
|
||||
const app = makeAuthApp({ nowMs: () => now });
|
||||
const login = await request(app).post("/v1/auth/login").send({ password: PASSWORD });
|
||||
const token = extractSessionCookie(login.headers["set-cookie"])!;
|
||||
now = start + SESSION_MAX_AGE_MS + 1;
|
||||
const res = await request(app)
|
||||
.get("/v1/auth/whoami")
|
||||
.set("Cookie", `${SESSION_COOKIE_NAME}=${token}`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("a protected /v1/* route returns 401 without a cookie", async () => {
|
||||
const app = makeAuthApp();
|
||||
const res = await request(app).get("/v1/pending-receipts");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("a protected /v1/* route is reachable with a valid cookie", async () => {
|
||||
// The route still 503s on missing chain deps, but it gets past the gate.
|
||||
const app = makeAuthApp();
|
||||
const login = await request(app).post("/v1/auth/login").send({ password: PASSWORD });
|
||||
const token = extractSessionCookie(login.headers["set-cookie"])!;
|
||||
const res = await request(app)
|
||||
.get("/v1/pending-receipts")
|
||||
.set("Cookie", `${SESSION_COOKIE_NAME}=${token}`);
|
||||
expect(res.status).not.toBe(401);
|
||||
});
|
||||
|
||||
it("GET /healthz is open even without a cookie", async () => {
|
||||
const app = makeAuthApp();
|
||||
const res = await request(app).get("/healthz");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createApp: /v1/auth/logout", () => {
|
||||
it("logout clears the cookie (Max-Age=0) and is idempotent", async () => {
|
||||
const app = makeAuthApp();
|
||||
const login = await request(app).post("/v1/auth/login").send({ password: PASSWORD });
|
||||
const token = extractSessionCookie(login.headers["set-cookie"])!;
|
||||
const out = await request(app)
|
||||
.post("/v1/auth/logout")
|
||||
.set("Cookie", `${SESSION_COOKIE_NAME}=${token}`);
|
||||
expect(out.status).toBe(204);
|
||||
const setCookie = out.headers["set-cookie"];
|
||||
const flat = Array.isArray(setCookie) ? setCookie.join(";") : setCookie;
|
||||
expect(flat).toContain("Max-Age=0");
|
||||
// Idempotent: same call without a session still 204s.
|
||||
const again = await request(app).post("/v1/auth/logout");
|
||||
expect(again.status).toBe(204);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createApp without auth deps (test/library shape)", () => {
|
||||
it("does not gate /v1/* and does not mount /v1/auth/* routes", async () => {
|
||||
const app = createApp({});
|
||||
// No /v1/auth/login route → 404.
|
||||
const login = await request(app).post("/v1/auth/login").send({ password: "any" });
|
||||
expect(login.status).toBe(404);
|
||||
// /healthz still works.
|
||||
const health = await request(app).get("/healthz");
|
||||
expect(health.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,8 @@ describe("loadConfig(env)", () => {
|
||||
INDEXER_URL: "http://localhost:3001",
|
||||
BUYER_PORT: "3002",
|
||||
NETWORK_ID: "0",
|
||||
BUYER_PASSWORD: "hunter2",
|
||||
SESSION_SECRET: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
};
|
||||
|
||||
it("returns a BuyerConfig when all required env vars are present", () => {
|
||||
@@ -27,6 +29,9 @@ describe("loadConfig(env)", () => {
|
||||
expect(config.indexerUrl).toBe(VALID_ENV.INDEXER_URL);
|
||||
expect(config.port).toBe(3002);
|
||||
expect(config.networkId).toBe(0);
|
||||
expect(config.password).toBe(VALID_ENV.BUYER_PASSWORD);
|
||||
expect(config.sessionSecret).toBe(VALID_ENV.SESSION_SECRET);
|
||||
expect(config.cookieSecure).toBe(true);
|
||||
});
|
||||
|
||||
it("throws when BUYER_PRIV_KEY_HEX is missing", () => {
|
||||
@@ -54,6 +59,34 @@ describe("loadConfig(env)", () => {
|
||||
const config = loadConfig(env);
|
||||
expect(config.networkId).toBe(0);
|
||||
});
|
||||
|
||||
it("throws when BUYER_PASSWORD is missing", () => {
|
||||
const env = { ...VALID_ENV };
|
||||
delete (env as Record<string, string | undefined>).BUYER_PASSWORD;
|
||||
expect(() => loadConfig(env)).toThrow(/BUYER_PASSWORD/);
|
||||
});
|
||||
|
||||
it("throws when SESSION_SECRET is missing", () => {
|
||||
const env = { ...VALID_ENV };
|
||||
delete (env as Record<string, string | undefined>).SESSION_SECRET;
|
||||
expect(() => loadConfig(env)).toThrow(/SESSION_SECRET/);
|
||||
});
|
||||
|
||||
it("throws when SESSION_SECRET is shorter than 32 chars", () => {
|
||||
const env = { ...VALID_ENV, SESSION_SECRET: "too-short" };
|
||||
expect(() => loadConfig(env)).toThrow(/SESSION_SECRET/);
|
||||
});
|
||||
|
||||
it('honours COOKIE_SECURE="0" for plain-HTTP loopback dev', () => {
|
||||
const env = { ...VALID_ENV, COOKIE_SECURE: "0" };
|
||||
const config = loadConfig(env);
|
||||
expect(config.cookieSecure).toBe(false);
|
||||
});
|
||||
|
||||
it("throws when COOKIE_SECURE is not 0 or 1", () => {
|
||||
const env = { ...VALID_ENV, COOKIE_SECURE: "true" };
|
||||
expect(() => loadConfig(env)).toThrow(/COOKIE_SECURE/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── createApp tests ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -30,6 +30,7 @@ import Wallet from "../../buyer/src/ui/pages/Wallet.js";
|
||||
import SupplierCard from "../../buyer/src/ui/components/SupplierCard.js";
|
||||
import PromptForm from "../../buyer/src/ui/components/PromptForm.js";
|
||||
import { MarketplaceProvider } from "../../buyer/src/ui/state/MarketplaceContext.js";
|
||||
import { AuthProvider } from "../../buyer/src/ui/state/AuthContext.js";
|
||||
import type { SupplierView } from "../../buyer/src/sdk/types.js";
|
||||
import { buildBuyerWalletKey } from "../fixtures/buyer-side/wallet-keys.js";
|
||||
import {
|
||||
@@ -83,9 +84,11 @@ function makeSampleSupplierView(overrides: Partial<SupplierView> = {}): Supplier
|
||||
function wrap(element: React.ReactElement, mp: Marketplace) {
|
||||
return (
|
||||
<MemoryRouter>
|
||||
<MarketplaceProvider marketplace={mp}>
|
||||
{element}
|
||||
</MarketplaceProvider>
|
||||
<AuthProvider initialStatus="authenticated">
|
||||
<MarketplaceProvider marketplace={mp}>
|
||||
{element}
|
||||
</MarketplaceProvider>
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user