fix(admin): renew browser session in place instead of a rotting admin credential (task #6) - #467
Open
TennyZhuang wants to merge 3 commits into
Open
fix(admin): renew browser session in place instead of a rotting admin credential (task #6)#467TennyZhuang wants to merge 3 commits into
TennyZhuang wants to merge 3 commits into
Conversation
…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>
…, 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/userinfoeach request. That credential rots independently of the 14-day Hands session: Raft's token lives ~1h and issues norefresh_token, and aSIGNED_URL_SECRETrotation makes the ciphertext undecryptable. Result: the outer session stays valid (app pages work) while/admin401s 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_rolecomes 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— returnSESSION_REAUTH_REQUIREDfor the browser-recoverable failures (ciphertext won't decrypt = key rotated; Raft rejects the token = expired/member-removed). The NULL-ciphertext branch staysADMIN_RELOGIN_REQUIREDso agent/API sessions (for which NULL is legitimate) are never told to re-auth.admin/src/lib/api.ts— onSESSION_REAUTH_REQUIREDthe 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— distinguishADMIN_AUTH_UNAVAILABLE(server config → contact ops, no login) from the recoverable re-login fallback.Gates (Yaoheng's acceptance)
/admin200 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.tsc+ admintsc+ adminvite buildclean; full worker suite green (the onesqlite3-ENOENTfailure 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
52625446hands_admin.tsbranches the NULL-ciphertext case byprincipal_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).admin/src/lib/apiReauth.dom.test.ts(jsdom, +5) locks the 4 gates.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):Do not mark task #6 done on Box A alone.