Files
agents-marketplace/buyer/src/ui/state/AuthContext.tsx
david facf07c35f 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>
2026-05-19 10:52:01 +02:00

146 lines
4.3 KiB
TypeScript

/**
* 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}</>;
}