Skip to content

fix(admin): renew browser session in place instead of a rotting admin credential (task #6) - #467

Open
TennyZhuang wants to merge 3 commits into
mainfrom
rhea/admin-session-unify-task6
Open

fix(admin): renew browser session in place instead of a rotting admin credential (task #6)#467
TennyZhuang wants to merge 3 commits into
mainfrom
rhea/admin-session-unify-task6

Conversation

@TennyZhuang

@TennyZhuang TennyZhuang commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Problem (task #6 — admin 401, ADMIN_RELOGIN_REQUIRED)

The admin console does a live Raft role re-check by storing the user's Raft access token (raft_access_token_ciphertext) on the session and calling /api/oauth/userinfo each request. That credential rots independently of the 14-day Hands session: Raft's token lives ~1h and issues no refresh_token, and a SIGNED_URL_SECRET rotation makes the ciphertext undecryptable. Result: the outer session stays valid (app pages work) while /admin 401s forever — "logged in but can't enter admin" — and the SPA collapsed six distinct 401 codes into one misleading "sign in again".

Design (confirmed against the Raft OAuth contract)

Raft's token response is {access_token, token_type, expires_in:3600}no refresh_token (D0 out); server_role comes only from user-token-scoped userinfo/serverinfo — no service-credential role endpoint (D2 out). So the fix is in-place browser-session renewal (Yaoheng's refined option 3 / Argus's silent re-auth): when the credential rots, renew the whole session with one full-page Login-with-Raft, which is seamless while the user's Raft session is alive.

Changes

  • worker/src/middleware/hands_admin.ts — return SESSION_REAUTH_REQUIRED for the browser-recoverable failures (ciphertext won't decrypt = key rotated; Raft rejects the token = expired/member-removed). The NULL-ciphertext branch stays ADMIN_RELOGIN_REQUIRED so agent/API sessions (for which NULL is legitimate) are never told to re-auth.
  • admin/src/lib/api.ts — on SESSION_REAUTH_REQUIRED the shared request layer does one full-page renewal (/api/auth/login?return=<path>) that atomically replaces the browser session. Single-flight + 30s loop guard: a renewal that comes back still-failing (Raft not logged in, or user removed) falls through to the manual page instead of redirect-looping.
  • admin/src/pages/HandsAdmin.tsx — distinguish ADMIN_AUTH_UNAVAILABLE (server config → contact ops, no login) from the recoverable re-login fallback.

Gates (Yaoheng's acceptance)

  1. Only browser sessions renew; agent/API never redirect (SPA is browser-only; NULL-ciphertext stays a non-reauth code). ✅
  2. Single-flight + loop guard. ✅
  3. Config/403 never re-login. ✅
  4. Renew → /admin 200 and per-request live role revocation still immediate (existing owner/admin→200, member/viewer→403 tests). ✅

Verification

  • worker/test/hands_admin_access.test.ts +4 (agent-safe NULL → ADMIN_RELOGIN_REQUIRED; decrypt-fail & Raft-reject → SESSION_REAUTH_REQUIRED; config → ADMIN_AUTH_UNAVAILABLE) — 11/11.
  • Worker tsc + admin tsc + admin vite build clean; full worker suite green (the one sqlite3-ENOENT failure is environment-only — absent locally, green in CI).

Honest residual constraint

Raft's 1h token + no refresh means active admin use hits a seamless auto-renewal ~hourly (a brief redirect, no password/consent while Raft is logged in). A truly gap-free admin session would need a Raft-side service-role endpoint or a longer token — a cross-Raft ask, out of scope here.

Not deployed by this PR — deploy runs through the migration/deploy gate after review.

🤖 Generated with Claude Code


Review round 1 (Yaoheng) — fixed at 52625446

  • human+NULL now renews: hands_admin.ts branches the NULL-ciphertext case by principal_type — human+NULL → SESSION_REAUTH_REQUIRED (the likely shape of artin's current lock-out), agent+NULL → ADMIN_RELOGIN_REQUIRED (never browser-renew an agent).
  • frontend reauth tests: admin/src/lib/apiReauth.dom.test.ts (jsdom, +5) locks the 4 gates.
  • wording: the renewal is a client-side switch to a new session (localStorage token), not a server-side atomic replace; the old D1 row just expires.

Gate ⑤ — pre-deploy verification (REQUIRED before task #6 is marked done)

This PR fixes the credential rot (Box A: artin's current ADMIN_RELOGIN_REQUIRED). It does not touch the separate, still-unexplained "re-login also fails" (Box B) — a fresh login always writes a ciphertext (auth.ts:577), so it never hits the NULL branch. Box B is verified only empirically, by a test admin (e.g. Artea), not artin, on prod (no staging exists):

Steps: fully log out (clear hands_session / hands:auth-token) → open /admin → Continue with Raft → complete the flow → land on /admin.

  • /admin 200 ⇒ the fresh-login path is healthy ⇒ Box B is not a general bug; artin's "re-login fails" was his own stale session/cookie, which this PR's human+NULL renewal handles. This PR closes the incident.
  • Fails (400 "Missing or invalid browser login proof", or a 401 loop) ⇒ Box B is a real fresh-login bug (prime suspect: the fix(auth): bind browser login state to local proof #460 browser-login proof cookie — 10-min state TTL / browser cookie policy / prod cookie domain). This PR alone does not resolve artin → a separate fix is required.

Do not mark task #6 done on Box A alone.

…dmin credential (task #6)

The admin console live-re-checked the Raft role by storing the user's Raft access
token (raft_access_token_ciphertext) on the session and calling /api/oauth/userinfo
each request. That credential rots independently of the 14-day Hands session: Raft's
token lives ~1h and issues no refresh_token, and a SIGNED_URL_SECRET rotation makes
the ciphertext undecryptable. Result: the outer session stays valid (app pages work)
while /admin 401s forever — "logged in but can't enter admin" — and the SPA collapsed
six distinct 401 codes into one misleading "sign in again".

Fix (Yaoheng's refined option 3 / Argus's silent re-auth; confirmed against the Raft
OAuth contract — no refresh_token, no service-credential role endpoint, so D0/D2 are out):

- worker/src/middleware/hands_admin.ts: return SESSION_REAUTH_REQUIRED for the
  browser-recoverable failures (ciphertext won't decrypt = key rotated; Raft rejects
  the token = expired or member removed). The NULL-ciphertext branch stays
  ADMIN_RELOGIN_REQUIRED so agent/API sessions (for which NULL is legitimate) are
  never told to re-auth.
- admin/src/lib/api.ts: on SESSION_REAUTH_REQUIRED the shared request layer does one
  full-page Login-with-Raft renewal (/api/auth/login?return=<path>) that atomically
  replaces the whole browser session. Single-flight + 30s loop guard: if it comes back
  still failing (Raft not logged in, or user removed), fall through to the manual page
  instead of redirect-looping.
- admin/src/pages/HandsAdmin.tsx: distinguish ADMIN_AUTH_UNAVAILABLE (server config —
  contact ops, no login) from the recoverable re-login fallback.

One login layer, no {live session + dead credential} half-state, live per-request role
revocation preserved. Honest residual: Raft's 1h token + no refresh means active admin
use hits a seamless auto-renewal ~hourly; gap-free would need a Raft-side service-role
endpoint or longer token (cross-Raft ask).

Tests: worker/test/hands_admin_access.test.ts +4 (agent-safe NULL; decrypt-fail and
Raft-reject -> SESSION_REAUTH_REQUIRED; config -> ADMIN_AUTH_UNAVAILABLE); existing
owner/admin->200 + member/viewer->403 cover renew + immediate revocation. Worker tsc +
admin tsc + admin vite build clean; full worker suite green (the one sqlite3-ENOENT
failure is env-only, green in CI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Rhea Rafferty <hands-rhea@mail.build>
@TennyZhuang
TennyZhuang requested a review from bytemain as a code owner August 19, 2026 09:03
Rhea Rafferty and others added 2 commits August 19, 2026 09:16
…, honest wording (task #6)

Yaoheng review of b73e3eb — 2 blockers:

1. Human sessions with a NULL raft_access_token_ciphertext (legacy / captured before
   the credential existed) were left on ADMIN_RELOGIN_REQUIRED, which does NOT trigger
   the new renewal — likely artin's current lock-out shape, so the fix would not have
   reached him. Branch hands_admin.ts by principal_type: human+NULL -> SESSION_REAUTH_REQUIRED
   (renew); agent+NULL -> ADMIN_RELOGIN_REQUIRED (agents legitimately have none and must
   never be sent through a browser re-auth). +2 tests, both paths.

2. The frontend reauth behavior had no tests. Add a jsdom test
   (admin/src/lib/apiReauth.dom.test.ts, +5) locking the four gates: redirect only on
   SESSION_REAUTH_REQUIRED, carrying the return path; single-flight / 30s loop guard
   (a second within the window errors to the manual page — no redirect loop); no redirect
   on ADMIN_AUTH_UNAVAILABLE (config) / 403 / the legacy ADMIN_RELOGIN_REQUIRED.

Non-blocker: corrected the api.ts comment — the renewal is a client-side switch to a new
session (localStorage token), NOT a server-side atomic replace; the old D1 row is not
revoked, it just expires.

Scope note (Argus): blocker 1 closes the CURRENT lock-out (human+NULL) but NOT the
separate, still-unexplained "re-login also fails" — a fresh login always writes a
ciphertext (auth.ts:577), so it never hits the NULL branch. That second box needs the
empirical gate 5 (a real browser fresh-login -> /admin 200 by a test admin, not artin);
prime candidate is the #460 browser-login proof cookie (400 "Missing or invalid browser
login proof"): 10-min state TTL / browser cookie policy / prod cookie domain.

Verified: worker hands_admin_access 12/12 + tsc 0; admin suite 38/38 + tsc 0 + vite build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Rhea Rafferty <hands-rhea@mail.build>
requireHandsAdmin mapped every non-2xx from Raft /api/oauth/userinfo to
SESSION_REAUTH_REQUIRED, collapsing three distinct world states into one
reading and dragging normal users into repeated logins on a Raft outage
or a permission denial. Branch by status instead:

  401           -> SESSION_REAUTH_REQUIRED (token invalid/expired/removed;
                   recoverable by browser session renewal)
  403           -> HANDS_ADMIN_REQUIRED    (permission decision, not login)
  429/5xx/other -> ADMIN_VERIFICATION_UNAVAILABLE (503; transient, retry —
                   re-login would not help and worsens rate-limiting)

Widen deny() to 503 ("unavailable"). Worker tests cover the 403 and the
429/500/502/503 branches; the frontend jsdom lock adds 503 to the set of
codes that must NOT trigger a login redirect.

Addresses review blocker 3 (hands_admin.ts:72).

Signed-off-by: Rhea Rafferty <rhea@hands.build>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant