Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions admin/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,35 @@ export interface Invite {
invite_url?: string;
}

// When the Worker reports SESSION_REAUTH_REQUIRED, the browser session's Raft
// re-check credential rotted or expired (the ~1h Raft access token, or a key
// change). Renew it with one full-page Login-with-Raft: if the user's Raft session
// is still alive this returns without interaction and the browser switches to the
// new Hands session (the callback stores a fresh token); if it isn't, a real login
// is shown. This is a client-side switch to a new session — it does not revoke the
// old D1 session server-side (that row simply expires).
const REAUTH_AT_KEY = "hands:admin-reauth-at";
const REAUTH_MIN_INTERVAL_MS = 30_000;

function triggerSessionReauth(): Promise<never> | null {
// Loop guard / single-flight: if we renewed moments ago and are still being told
// to, the renewal did not fix it (user removed from the server, or Raft not logged
// in so the redirect bounced straight back). Fall through to the normal error so
// the manual "Sign in again" page shows instead of redirect-looping.
try {
const last = Number(window.sessionStorage.getItem(REAUTH_AT_KEY) || 0);
if (Date.now() - last < REAUTH_MIN_INTERVAL_MS) return null;
window.sessionStorage.setItem(REAUTH_AT_KEY, String(Date.now()));
} catch {
return null; // no sessionStorage → don't risk a loop; show the manual page
}
clearAuthToken();
const back = window.location.pathname + window.location.search;
window.location.href = `/api/auth/login?return=${encodeURIComponent(back)}`;
// Navigation is underway; never resolve so callers don't flash an error first.
return new Promise<never>(() => {});
}

async function request<T>(
path: string,
init: RequestInit & { admin?: boolean } = {},
Expand All @@ -492,6 +521,15 @@ async function request<T>(
// non-JSON body; leave as text
}
if (!res.ok) {
if (
res.status === 401 &&
typeof body === "object" &&
body &&
(body as any).code === "SESSION_REAUTH_REQUIRED"
) {
const reauth = triggerSessionReauth();
if (reauth) return reauth as unknown as Promise<T>;
}
const msg =
typeof body === "object" && body && "error" in body
? String((body as any).error)
Expand Down
97 changes: 97 additions & 0 deletions admin/src/lib/apiReauth.dom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// @vitest-environment jsdom
//
// The shared request layer renews the browser session on SESSION_REAUTH_REQUIRED
// (the admin console's "one layer, no half-session" fix). These tests lock the four
// gates: it redirects only on that code, carries the return path, is single-flight /
// loop-guarded, and never redirects on config errors or 403.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

let hrefLog: string[] = [];

function setLocation(pathname: string, search = "") {
Object.defineProperty(window, "location", {
configurable: true,
value: {
pathname,
search,
hash: "",
get href() {
return hrefLog[hrefLog.length - 1] ?? "";
},
set href(v: string) {
hrefLog.push(v);
},
},
});
}

function mockJson(status: number, body: unknown) {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } })),
);
}

// Fresh module per test so the module-load consumeAuthTokenFromUrl() and closures reset.
async function loadApi() {
vi.resetModules();
return import("./api");
}

beforeEach(() => {
hrefLog = [];
setLocation("/admin", "?x=1");
window.sessionStorage.clear();
window.localStorage.clear();
});
afterEach(() => vi.unstubAllGlobals());

describe("SESSION_REAUTH_REQUIRED renewal (shared request layer)", () => {
it("redirects to Login with Raft carrying the current path as return", async () => {
const api = await loadApi();
mockJson(401, { error: "unauthorized", code: "SESSION_REAUTH_REQUIRED" });
// The renewal returns a never-resolving promise; assert the side effect instead.
void api.getHandsAdminOverview();
await vi.waitFor(() => expect(hrefLog.length).toBe(1));
expect(hrefLog[0]).toBe(`/api/auth/login?return=${encodeURIComponent("/admin?x=1")}`);
});

it("is single-flight / loop-guarded: a second SESSION_REAUTH_REQUIRED within the window does not redirect again, it errors", async () => {
const api = await loadApi();
mockJson(401, { error: "unauthorized", code: "SESSION_REAUTH_REQUIRED" });
void api.getHandsAdminOverview();
await vi.waitFor(() => expect(hrefLog.length).toBe(1));
// Second call, still SESSION_REAUTH_REQUIRED: the 30s guard blocks a re-redirect,
// so it surfaces the error (→ the manual "Sign in again" fallback) instead of looping.
await expect(api.getHandsAdminOverview()).rejects.toMatchObject({ status: 401 });
expect(hrefLog.length).toBe(1);
});

it("does NOT redirect on ADMIN_AUTH_UNAVAILABLE (server config, not a login problem)", async () => {
const api = await loadApi();
mockJson(401, { error: "unauthorized", code: "ADMIN_AUTH_UNAVAILABLE" });
await expect(api.getHandsAdminOverview()).rejects.toMatchObject({ status: 401 });
expect(hrefLog.length).toBe(0);
});

it("does NOT redirect on 403 (not an administrator)", async () => {
const api = await loadApi();
mockJson(403, { error: "forbidden", code: "HANDS_ADMIN_REQUIRED" });
await expect(api.getHandsAdminOverview()).rejects.toMatchObject({ status: 403 });
expect(hrefLog.length).toBe(0);
});

it("does NOT redirect on 503 ADMIN_VERIFICATION_UNAVAILABLE (transient Raft outage — retry, not re-login)", async () => {
const api = await loadApi();
mockJson(503, { error: "unavailable", code: "ADMIN_VERIFICATION_UNAVAILABLE" });
await expect(api.getHandsAdminOverview()).rejects.toMatchObject({ status: 503 });
expect(hrefLog.length).toBe(0);
});

it("does NOT redirect on the legacy ADMIN_RELOGIN_REQUIRED (agent/no-credential — never browser-renew)", async () => {
const api = await loadApi();
mockJson(401, { error: "unauthorized", code: "ADMIN_RELOGIN_REQUIRED" });
await expect(api.getHandsAdminOverview()).rejects.toMatchObject({ status: 401 });
expect(hrefLog.length).toBe(0);
});
});
11 changes: 10 additions & 1 deletion admin/src/pages/HandsAdmin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,16 @@ export function HandsAdmin() {
const overview = useQuery({ queryKey: ["hands-admin", "overview"], queryFn: getHandsAdminOverview, retry: false });
if (overview.isPending) return <main className="p-8 text-sm text-slate-500">Loading observability…</main>;
if (overview.error instanceof ApiError && overview.error.status === 401) {
return <main className="p-8"><h1 className="text-xl font-semibold">Sign in again</h1><p className="mt-2 text-sm text-slate-600">A fresh Raft session is required for live administrator verification.</p><a className="mt-4 inline-block text-sky-700" href="/api/auth/login?return=%2Fadmin">Continue with Raft</a></main>;
const code = (overview.error.body as { code?: string } | null)?.code;
if (code === "ADMIN_AUTH_UNAVAILABLE") {
// Server-side configuration problem (missing secret / Raft origin). Re-login
// cannot fix it — do not send the operator into a login loop.
return <main className="p-8"><h1 className="text-xl font-semibold">Admin verification unavailable</h1><p className="mt-2 text-sm text-slate-600">Live administrator verification is temporarily unavailable because of a server configuration issue. This is not a login problem — please contact an operator.</p></main>;
}
// SESSION_REAUTH_REQUIRED is normally renewed in the API layer before it reaches
// here; this manual page is the fallback when the loop guard tripped (Raft not
// logged in, or the user is no longer a member of the server).
return <main className="p-8"><h1 className="text-xl font-semibold">Sign in again</h1><p className="mt-2 text-sm text-slate-600">Your administrator session needs a fresh Raft sign-in.</p><a className="mt-4 inline-block text-sky-700" href="/api/auth/login?return=%2Fadmin">Continue with Raft</a></main>;
}
if (overview.error instanceof ApiError && overview.error.status === 403) {
return <main className="p-8"><h1 className="text-xl font-semibold">Administrator access required</h1><p className="mt-2 text-sm text-slate-600">This page is limited to administrators of an approved Raft server.</p></main>;
Expand Down
41 changes: 36 additions & 5 deletions worker/src/middleware/hands_admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ type LiveRaftUser = {
server_role?: string;
};

function deny(c: Context, status: 401 | 403, code: string) {
return c.json({ error: status === 401 ? "unauthorized" : "forbidden", code }, status);
function deny(c: Context, status: 401 | 403 | 503, code: string) {
const error = status === 401 ? "unauthorized" : status === 403 ? "forbidden" : "unavailable";
return c.json({ error, code }, status);
}

export const requireHandsAdmin: MiddlewareHandler<AdminEnv & { Bindings: Env }> = async (c, next) => {
Expand All @@ -34,7 +35,18 @@ export const requireHandsAdmin: MiddlewareHandler<AdminEnv & { Bindings: Env }>
LIMIT 1`,
).bind(await sha256Hex(sessionToken), account.id, Date.now())
.first<{ raft_access_token_ciphertext: string | null }>();
if (!session?.raft_access_token_ciphertext) return deny(c, 401, "ADMIN_RELOGIN_REQUIRED");
if (!session?.raft_access_token_ciphertext) {
// A human browser session with no stored Raft credential (a legacy session, or
// one from before the credential was captured) is recoverable by renewing the
// browser session — a fresh Login-with-Raft stores one. An agent/CLI session
// legitimately never has this credential and must NOT be sent through a browser
// re-auth; it just cannot use the admin console.
return deny(
c,
401,
account.principal_type === "agent" ? "ADMIN_RELOGIN_REQUIRED" : "SESSION_REAUTH_REQUIRED",
);
}

const secret = c.env.SIGNED_URL_SECRET || c.env.RAFT_CLIENT_SECRET;
if (!secret || !c.env.RAFT_API_ORIGIN) return deny(c, 401, "ADMIN_AUTH_UNAVAILABLE");
Expand All @@ -43,12 +55,31 @@ export const requireHandsAdmin: MiddlewareHandler<AdminEnv & { Bindings: Env }>
try {
accessToken = await decryptAdminRaftToken(secret, session.raft_access_token_ciphertext);
} catch {
return deny(c, 401, "ADMIN_RELOGIN_REQUIRED");
// A non-NULL ciphertext that won't decrypt is a browser session whose Raft
// credential rotted (e.g. the encryption key changed). It is recoverable by
// renewing the browser session (a fresh Login-with-Raft re-encrypts with the
// current key) — signal that, not the generic relogin. Agent/API sessions hit
// the NULL-ciphertext branch above and are never told to re-auth.
return deny(c, 401, "SESSION_REAUTH_REQUIRED");
}
const response = await fetch(new URL("/api/oauth/userinfo", c.env.RAFT_API_ORIGIN), {
headers: { authorization: `Bearer ${accessToken}` },
});
if (!response.ok) return deny(c, 401, "AUTH_EXPIRED");
// Distinguish WHY Raft refused — three different world states must not collapse into
// one reading, or transient outages and permission denials drag normal users into
// repeated logins:
// 401 — token invalid/expired (or the user was removed): recoverable by a browser
// session renewal (a valid user continues; a removed user's renewed token
// still fails userinfo, so the frontend loop guard falls back to the manual
// page — still denied, role revocation stays immediate).
// 403 — a permission decision: not a login problem, do not renew.
// 429 / 5xx / anything else — a transient Raft outage: re-login would not help and
// retrying worsens rate-limiting, so surface "temporarily unavailable".
if (!response.ok) {
if (response.status === 401) return deny(c, 401, "SESSION_REAUTH_REQUIRED");
if (response.status === 403) return deny(c, 403, "HANDS_ADMIN_REQUIRED");
return deny(c, 503, "ADMIN_VERIFICATION_UNAVAILABLE");
}

const live = await response.json<LiveRaftUser>();
const allowedServers = (c.env.HANDS_ADMIN_ALLOWED_SERVER_IDS || "")
Expand Down
63 changes: 57 additions & 6 deletions worker/test/hands_admin_access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,33 @@ function database(ciphertext: string | null) {
} as unknown as D1Database;
}

async function request(role: string, serverId = "server-allowed", ciphertext?: string | null) {
async function request(
role: string,
serverId = "server-allowed",
ciphertext?: string | null,
opts: { userinfoOk?: boolean; userinfoStatus?: number; secret?: string | null; principalType?: "human" | "agent" } = {},
) {
const secret = "test-secret";
const encrypted = ciphertext === undefined ? await encryptAdminRaftToken(secret, "raft-token") : ciphertext;
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({
sub: account.provider_subject, server_id: serverId, server_role: role,
}), { headers: { "content-type": "application/json" } })));
const userinfoStatus = opts.userinfoStatus ?? (opts.userinfoOk === false ? 401 : 200);
const userinfoOk = userinfoStatus >= 200 && userinfoStatus < 300;
vi.stubGlobal("fetch", vi.fn(async () => new Response(
userinfoOk
? JSON.stringify({ sub: account.provider_subject, server_id: serverId, server_role: role })
: "error",
{ status: userinfoStatus, headers: { "content-type": "application/json" } },
)));
const app = new Hono<any>();
app.use("*", async (c, next) => { c.set("admin_account", account); await next(); });
app.use("*", async (c, next) => {
c.set("admin_account", { ...account, principal_type: opts.principalType ?? account.principal_type });
await next();
});
app.use("/admin/*", requireHandsAdmin);
app.get("/admin/overview", (c) => c.json({ ok: true }));
const env = {
DB: database(encrypted), SIGNED_URL_SECRET: secret, RAFT_API_ORIGIN: "https://api.raft.build",
DB: database(encrypted),
SIGNED_URL_SECRET: opts.secret === undefined ? secret : (opts.secret ?? undefined),
RAFT_API_ORIGIN: "https://api.raft.build",
HANDS_ADMIN_ALLOWED_SERVER_IDS: "server-allowed",
} as unknown as Env;
return app.request("https://app.hands.build/admin/overview", { headers: { authorization: "Bearer hands-session" } }, env);
Expand All @@ -40,6 +55,42 @@ describe("requireHandsAdmin", () => {
it.each(["member", "viewer"])("denies live server role %s before the handler", async (role) => expect((await request(role)).status).toBe(403));
it("denies another Raft server", async () => expect((await request("admin", "other-server")).status).toBe(403));
it("requires a fresh login for sessions without a live Raft credential", async () => expect((await request("admin", "server-allowed", null)).status).toBe(401));
it("renews a HUMAN browser session with no stored credential (legacy/missing-ciphertext — the current lock-out shape)", async () => {
const res = await request("admin", "server-allowed", null, { principalType: "human" });
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: "unauthorized", code: "SESSION_REAUTH_REQUIRED" });
});
it("does NOT tell an AGENT/CLI session to re-auth (NULL ciphertext is legitimate for agents — never browser-renew)", async () => {
const res = await request("admin", "server-allowed", null, { principalType: "agent" });
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: "unauthorized", code: "ADMIN_RELOGIN_REQUIRED" });
});
it("renews the browser session when the stored credential can't be decrypted (key rotated)", async () => {
const wrongKey = await encryptAdminRaftToken("rotated-secret", "raft-token");
const res = await request("admin", "server-allowed", wrongKey);
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: "unauthorized", code: "SESSION_REAUTH_REQUIRED" });
});
it("renews the browser session when Raft rejects the token (expired, or member removed)", async () => {
const res = await request("admin", "server-allowed", undefined, { userinfoOk: false });
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: "unauthorized", code: "SESSION_REAUTH_REQUIRED" });
});
it("does NOT renew on a Raft 403 (permission decision, not a login problem) — maps to admin-required", async () => {
const res = await request("admin", "server-allowed", undefined, { userinfoStatus: 403 });
expect(res.status).toBe(403);
expect(await res.json()).toEqual({ error: "forbidden", code: "HANDS_ADMIN_REQUIRED" });
});
it.each([429, 500, 502, 503])("does NOT renew on a transient Raft %i — surfaces verification-unavailable, not re-login", async (status) => {
const res = await request("admin", "server-allowed", undefined, { userinfoStatus: status });
expect(res.status).toBe(503);
expect(await res.json()).toEqual({ error: "unavailable", code: "ADMIN_VERIFICATION_UNAVAILABLE" });
});
it("reports config-unavailable (not a re-login) when the admin secret is missing", async () => {
const res = await request("admin", "server-allowed", "irrelevant-ciphertext", { secret: null });
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: "unauthorized", code: "ADMIN_AUTH_UNAVAILABLE" });
});
it("does not confuse app admin role with Raft server admin role", async () => {
// The stored account is an org/app admin, but live Raft says only member.
const response = await request("member");
Expand Down
Loading