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>
146 lines
4.3 KiB
TypeScript
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}</>;
|
|
}
|