Skip to content

feat(app): AuthSyncProvider FE architecture (PR 1/2 of Phase 4a auth fix)#175

Merged
rz1989s merged 20 commits intomainfrom
feat/authsync-architecture
May 7, 2026
Merged

feat(app): AuthSyncProvider FE architecture (PR 1/2 of Phase 4a auth fix)#175
rz1989s merged 20 commits intomainfrom
feat/authsync-architecture

Conversation

@rz1989s
Copy link
Copy Markdown
Member

@rz1989s rz1989s commented May 7, 2026

Summary

PR 1 of 2 for the Phase 4a auth + security proper-fix. This PR is the frontend half: a new AuthSyncProvider that owns auth state, the JWT lifecycle (decode + expiry watcher + preemptive refresh), the wallet ↔ JWT reconciliation, and the global 401 interceptor with session-expired toast. Components migrate off the legacy useAuth + useAppStore.token pair and onto a single useAuthState() source.

PR 2 (feat/auth-surface-hardening) will land the matching backend changes: 24h JWT TTL, /api/auth/refresh, SIWS verify endpoint, SENTINEL_MODE default flip, ESLint rule banning direct process.env.SOLANA_NETWORK reads, and /pay/:id/confirm fail-closed.

Spec + plan: see PR #174 (docs).

Commits (19 total)

# Commit Task Summary
1 `840c409` A1 JWT client-side decode helper
2 `80fa90c` A1 fixup Strip semicolons + add expiry-boundary jwt test
3 `cae0c36` A2 Expose VerifyResponse interface from auth client
4 `e7712e5` A3 `/api/auth/refresh` client wrapper
5 `bc086a9` A4 ToastProvider + Toast component
6 `0b12d62` A5 AuthSyncProvider with state machine
7 `b960b5e` A6 `authenticate()` signMessage path
8 `50ea00a` A6+ SIWS-then-signMessage path with graceful fallback
9 `2f2ea2c` A7 Disconnect cleanup + auto-clear on external disconnect
10 `7ba2481` A8 Preemptive JWT refresh + expiry-driven cleanup
11 `b2bfbca` A9+A10 Global 401 interceptor + toast wiring + App.tsx wrap
12 `d978c47` A11 WalletDropdown component (Copy / Re-sign / Disconnect)
13 `0c8e452` A0 Catch-up jwt.ts semicolon strip (80fa90c miss)
14 `b5f9ed4` A12 Header consumes useAuthState + WalletDropdown
15 `d82e579` A13 BottomNav uses `useAuthState().disconnect`
16 `d5dc4aa` A14 Chat surfaces errors as toast, not inline message
17 `8596165` A15 Components consume useAuthState + apiFetch end-to-end
18 `a5bcda3` A16 `useAuth` becomes thin re-export of AuthSyncProvider
19 `aee3682` A17 Block JWT-in-URL SSE fallback in production builds

What this PR fixes

From the QA findings consolidated in the spec:

  • FE H-2 (no JWT expiry watch / refresh) — preemptive refresh inside last 5min of TTL + cleanup timer at exact expiry.
  • FE H-3 (autoConnect produces fake-authed UI with no recovery) — Header status switch: `unauthed`/`connecting` → Connect, `expired` → amber Re-sign in, `authed` → WalletDropdown.
  • FE H-4 (raw token-error string painted into chat) — chat catch-block emits a toast for non-401 errors and lets the global interceptor handle 401s. Shared `isAuthError` helper in `lib/auth-errors` for consistency.
  • FE H-5 (no Disconnect path on desktop) — WalletDropdown Copy / Re-sign / Disconnect.
  • FE H-6 (wallet disconnect didn't clear JWT) — BottomNav routes through `useAuthState().disconnect` which atomically tears down both wallet-adapter state and the persisted token.
  • FE X-1 (single source of truth) — Header / BottomNav / ChatSidebar / SentinelConfirm / DashboardView / VaultView / useSSE all read auth via `useAuthState`.
  • FE X-2 (duplicated Authorization header) — direct `fetch` with manual `Bearer ${token}` removed; everything goes through `apiFetch` + the 401 interceptor.

Plus a security drive-by: `connectSSE` no longer falls back to a JWT-in-URL query param in production. The fallback only fires under `import.meta.env.DEV`.

Locked decision deviation: SIWS-then-signMessage with graceful fallback

The original spec called SIWS-only on Phantom/Solflare. To keep this PR independently mergeable BEFORE PR 2 ships the matching backend support, `authenticate()` tries SIWS first; if the wallet has no `signIn` adapter or the server rejects the SIWS verify request, it falls through to `signMessage` without re-prompting the user (user rejection from SIWS still propagates).

PR 2 adds a new task B7.5: SIWS verify endpoint support so SIWS can actually reach `one popup, no extra prompt` on Phantom/Solflare. Until B7.5 ships, every wallet authenticates via `signMessage`; once B7.5 ships, Phantom/Solflare upgrade automatically without a frontend redeploy.

Test plan

  • `pnpm test -- --run` from `app/` — 143/143 passing (124 baseline + 19 new across JWT helper, auth client, refresh client, ToastProvider, AuthSyncProvider, WalletDropdown, Header, ChatSidebar, SentinelConfirm, VaultView, sse picker, apiFetch 204 handling)
  • `tsc --noEmit` from `app/` — clean
  • `pnpm test -- --run` from repo root — 555/555 passing (no regressions in REST routes)
  • `pnpm test -- --run` from `packages/agent` — 1308/1308 passing (no regressions; required `pnpm --filter @sipher/sdk build` first to populate `packages/sdk/dist`)
  • Manual auth flow QA on Phantom on the deployed VPS (devnet beta) — gated on PR 2 + this PR landing together so SIWS upgrades end-to-end. Until then, manual smoke = `signMessage` path + 24h TTL on a fresh login.

Out of scope (deferred)

  • HeraldView / SquadView still receive token as a prop. App.tsx still feeds them from the legacy `useAuth` re-export. Will land as cleanup in PR 3 polish.
  • `useAuth.ts` re-exports the AuthSyncProvider context. Direct deletion (and conversion of App.tsx to import `useAuthState` directly) deferred to PR 3.
  • BE auth-surface hardening (24h TTL, refresh endpoint, SIWS verify, ephemeral state, `/pay/:id/confirm` fail-closed, SENTINEL_MODE default flip) — that's PR 2.

rz1989s added 20 commits May 6, 2026 15:02
Adds decodeJwtPayload, isJwtExpired, getJwtExpiresAt for client-side
TTL checks on the persisted JWT. No signature verification — purely
for expiry-watch and graceful re-auth. Used by AuthSyncProvider in
subsequent commits.
- Remove trailing semicolons from jwt.test.ts to match project no-semicolons
  style (jwt.ts was already compliant)
- Add isJwtExpired test at exact-expiry second to lock >= semantics
- Extract VerifyResponse + NonceResponse interfaces (was inline types)
- Add app/src/api/__tests__/auth.test.ts covering nonce request, verify
  preserves expiresIn + isAdmin, error path
- isAdmin stays required (server contract guarantees it on every response)

AuthSyncProvider will read expiresIn to schedule preemptive refresh.
Returns { token, expiresIn } on 200. Returns null on 425 (server says too
early — try again later) or 404 (endpoint not deployed — graceful
degradation; older sipher BE responds 404 on POST /api/auth/refresh).
Throws on 401 (force full re-sign) or 5xx.

AuthSyncProvider's expiry watcher will call this within 5min of token
exp to swap in a fresh JWT without user interaction.
Toast slot for surfacing 401 expiry, sign-in errors, network issues. Used
by apiFetch interceptor + AuthSyncProvider in subsequent commits.

- 4 kinds (info / warn / error / success) with kind-specific tailwind tokens
- Optional action button (used for "Sign in" CTA on 401 expiry toast)
- Auto-dismiss after 7s by default; durationMs=0 makes toast sticky
- Phosphor X icon for dismiss; role=status + aria-live=polite for a11y
- Timer cleanup on unmount + on explicit dismiss (prevents stale setState)
- crypto.randomUUID for stable keys
- useToast() throws if called outside provider (catches wiring mistakes early)
Adds AuthSyncProvider that owns the auth state machine (status:
connecting/unauthed/authed/expired/error), token, expiresAt, isAdmin,
publicKey. Reconciles wallet-adapter state with the persisted JWT —
clears auth if the wallet changes mid-session OR if the persisted JWT
was issued for a wallet other than the currently-connected one (cold
reload after wallet swap).

Zustand store gains expiresAt: number | null + persist version: 1 with a
migrate fn that nukes v0 data (forces re-auth on first load after this
ships, so previously-persisted tokens without expiresAt don't stick
around indefinitely). setAuth picks up an optional expiresAt arg —
existing 2-arg callers (hooks/useAuth.ts) continue to work and pass null
until they migrate in subsequent tasks.

authenticate() and disconnect() are stubs in this commit; full SIWS-
then-signMessage flow lands in Task A6 and richer disconnect cleanup
in A7. Existing components not yet migrated; they continue to use
useWallet + useAppStore directly until Tasks A12-A17.
Implements the connect-and-sign flow inside AuthSyncProvider:

- /api/auth/nonce → wallet.signMessage(message) → /api/auth/verify
- Stores token, isAdmin, expiresAt (from server expiresIn) into Zustand
- status='connecting' while in flight
- Throws if wallet has no signMessage (escapes to caller for toast/UI)
- User-rejection from signMessage propagates as Error
- error field exposed via useAuthState() for inline UI surfacing

DEVIATION FROM PLAN: Spec D5 calls for SIWS-then-signMessage fallback,
but the backend's /api/auth/verify hardcodes the signed-message format
(\"sipher.sip-protocol.org wants you to sign in.\\n\\nNonce: \${nonce}\").
A wallet-standard signIn() returns a SIWS-structured message; the
signature would not verify against the server's hardcoded format.
SIWS support requires backend changes — track separately as a follow-up
once the backend learns to verify against the wallet-supplied signedMessage.

For now, signMessage handles all wallet-standard wallets uniformly
(Phantom, Solflare, Backpack, Jupiter, OKX). Functional parity with
existing useAuth.ts is preserved; AuthSyncProvider adds the state
machine + reconciliation effects that useAuth lacked.
Restores the full spec D5 flow that was deferred in b960b5e:

- Try wallet-standard signIn() first (Phantom/Solflare/Backpack path).
  Returns { signature, signedMessage }; both are forwarded to
  /api/auth/verify so the server can verify against the actual bytes
  the wallet signed (not a server-reconstructed string).
- User rejection from signIn() propagates as Error; no second wallet
  popup.
- SIWS errors that aren't user rejection (signIn not implemented,
  network blip, etc.) trigger graceful fallback to signMessage.
- After SIWS sign succeeds but before token is issued: if the server
  rejects (legacy behavior — the BE ignores signedMessage and tries to
  reconstruct a different message format), fall back to signMessage.
  This keeps PR 1 deployable BEFORE PR 2 ships SIWS server support.
- signMessage path unchanged: hex-encoded signature, server reconstructs
  message from nonce.

api/auth.ts: verifySignature gains optional signedMessage option
(base64 string of the bytes the wallet actually signed).

PAIRED WITH PR 2: adds signedMessage handling to /api/auth/verify so
SIWS-supporting wallets get the optimal one-popup connect+sign UX
once both PRs ship. Until then, all wallets see the legacy two-popup
signMessage path.

5 new tests cover SIWS happy path, server-rejects-fallback,
no-signature-fallback, non-rejection-error-fallback, and
user-rejection-propagation.
Two failure modes addressed:

1. disconnect() previously called walletDisconnect() then clearAuth().
   If walletDisconnect threw (extension closed mid-flight, hardware
   wallet unplugged) the JWT stayed in Zustand. Wrapped in try/finally
   so the JWT clears even if the wallet adapter rejects.

2. External disconnects (user clicks Disconnect in Phantom extension,
   browser wipes wallet-adapter state, etc.) bypass our disconnect()
   entirely. Added a useEffect that clears auth when connected goes
   false while a token is still in the store.

3 new tests: explicit disconnect happy path, disconnect-when-walletDisconnect-
throws, external-disconnect auto-clears.

Resolves FE H-6 (auth state desync between wallet-adapter and Zustand).
Schedules two timers per JWT lifetime:

- Cleanup timer: fires at exact expiry (or immediately if already expired
  on hydration), clearing the auth fields if refresh did not succeed.
- Refresh timer: fires (remainingSec - 5min) before expiry, calls
  /api/auth/refresh, swaps in the new token + expiresAt on success.

If the JWT is already inside the 5min window when the effect mounts
(cold reload near expiry, long-running tab), refresh fires immediately
without waiting for a timer. Refresh failures are intentionally swallowed
— the cleanup timer + the upcoming 401 interceptor (Task A9) handle the
reauth surface.

Effect cleanup function clears both timers when token/expiresAt change
(prevents stale timers leaking after refresh succeeds and re-runs the
effect with new values).

4 new tests: clears at expiry, immediate refresh in window, refresh
scheduled near expiry far from now, refresh-failure-then-clear path.

Resolves FE H-2 (no expiry watch / refresh — silent 401s after 1h).
apiFetch gains a module-scope interceptor registry (registerAuthInterceptor).
AuthSyncProvider registers a single handler on mount, unregisters on unmount.
Any 401 from any backend endpoint now triggers:

- clearAuth() — wipes token/isAdmin/expiresAt from Zustand
- warn toast \"Session expired — please sign in again.\" with 12s persistence
- 'Sign in' action button that re-runs authenticate() (uses authenticateRef
  to keep the latest closure without invalidating the interceptor effect)

The interceptor handler is wrapped in try/catch so a buggy handler can
never block the underlying request from throwing — auth-loss UX is best
effort.

App.tsx provider tree updated:
ConnectionProvider > WalletProvider > WalletModalProvider > ToastProvider
> AuthSyncProvider > AppShell. ToastProvider goes outside AuthSyncProvider
because AuthSync calls useToast(); AppShell goes inside both so any
component can read auth state via useAuthState().

Resolves FE H-3 (no global 401 interceptor — every fetch caller
reinvented it, leading to inconsistent error UX) and FE H-4 (raw
\"invalid or expired token\" string leaked into chat).

9 new client.ts tests cover happy path, structured error envelope,
legacy string error, 401 calls interceptor, non-401 doesn't, interceptor
crash doesn't propagate.
Plain Tailwind + Phosphor implementation; no Radix dep added per spec D6
(lean deps). Closes on outside mousedown, Escape key, or action click.
role=menu/menuitem + aria-haspopup/aria-expanded for screen readers.

Used by Header.tsx in Task A12 to replace the unclickable wallet pill.
Resolves FE H-5 (no desktop disconnect path) once wired in.

9 tests cover render, open/close toggle, three actions (each invokes its
callback and closes), outside click, Escape, aria-expanded reflection.
Commit 80fa90c claimed jwt.ts was already compliant with the project
no-semicolons rule, but the file still carried 9 trailing semicolons
from its initial introduction in 840c409. This catches it up.

Also drops a what-comment ("Use base64url decoding; atob handles
padded base64") that just narrated the next line. The why-comment
on the malformed-token branch is preserved because the fail-secure
intent (null exp -> treat as expired) is non-obvious from the code.

No behavior change. 124/124 app tests still pass.
Replaces the unclickable wallet pill with a status-aware switch:

- unauthed | connecting -> Connect button -> authenticate(); on rejection,
  surface error via toast.
- expired -> amber "Re-sign in" button -> authenticate(); preserves the
  pre-auth UI hint that the session lapsed instead of silently sending
  the user through a fresh Connect.
- authed + publicKey -> WalletDropdown with Copy / Re-sign / Disconnect
  actions.

Also drops the local useWallet + useWalletModal reads. authenticate()
in AuthSyncProvider already routes to setVisible(true) when there is no
connected wallet, so the modal still opens on first Connect click.

Resolves FE H-3 (autoConnect produces fake-authed UI with no recovery)
and FE H-5 (no Disconnect path on desktop). 10 new tests in
Header.test.tsx; 134/134 app tests green.
Was calling wallet-adapter's disconnect() directly without clearing the
persisted JWT (FE H-6). Now goes through useAuthState which atomically
tears down both wallet-adapter state and the Zustand persisted token.

Also reads isAdmin from useAuthState rather than the standalone
useIsAdmin hook so BottomNav has a single auth source. Adds an info
toast on disconnect and closes the More sheet before awaiting so the
overlay doesn't linger over the toast.
Previously the raw catch-block error string ("invalid or expired
token", "Error 429", etc.) was painted into the assistant's bubble via
appendToLast, leaving the user with a streaming-shaped message that was
actually an HTTP failure (FE H-4).

Now:
- 401-class errors (matching /401|expired|invalid token|unauthori[sz]ed/i)
  are suppressed at the chat layer; the global apiFetch interceptor's
  Session-expired toast already handles them with a Sign-in CTA.
- Other failures get a 6s error toast and the streaming placeholder
  finalizes via the existing finally branch.

Also migrates token reads from useAppStore to useAuthState so the chat
input is gated on the AuthSyncProvider's status, not just raw token
presence (status === 'expired' will report token=null after the expiry
watcher fires, disabling input until re-auth).

2 new tests cover toast-on-non-auth and silence-on-401.
- SentinelConfirm: drop the token prop, read from useAuthState, swap
  raw fetch for apiFetch so 401 routes through the global interceptor.
  Adopt the new lib/auth-errors helper to suppress double-display when
  the interceptor toasts a session-expired notice.
- DashboardView: drop the token prop, read token + isAdmin from
  useAuthState. Migrate fetchPrivacyScore from raw fetch to apiFetch
  (still supports AbortSignal via RequestInit).
- VaultView: drop the token prop, read from useAuthState.
- useSSE: drop the token parameter, read from useAuthState directly.
- App.tsx: stop threading token through to Dashboard / Vault / useSSE.
  HeraldView and SquadView still receive token as a prop (out of scope
  for this PR per the handoff; will follow in a polish pass).
- ChatSidebar: stop passing token to SentinelConfirm; switch to the
  shared isAuthError helper for the catch-suppression pattern.

apiFetch gains 204 No Content + content-length: 0 handling. The
promise-gate resolve/reject endpoints return 204 in production, and
res.json() on an empty stream throws — without this, every successful
SentinelConfirm dispatch failed silently. Two new client tests cover
the new branches.

lib/auth-errors centralises the auth-error regex used by ChatSidebar
and SentinelConfirm. The bare /expired/i pattern was greedy enough to
swallow the 404 "flag not found or expired" envelope, suppressing a
legitimate inline error. Tightened to specific phrasings the auth
middleware actually emits.

Resolves FE X-1 (single source of truth) + FE X-2 (duplicated
Authorization header).

138/138 app tests green; tsc --noEmit clean.
…ontext

App.tsx is the last importer of the legacy useAuth shape. Routing the
re-export through AuthSyncProvider's context lets the existing
{ token, isAdmin } destructure work unchanged while the rest of the
tree consolidates onto a single source of truth for auth state.

The full re-export of useAuthState (with all status/expiresAt/etc
fields) ships in a follow-up so the legacy two-field consumers don't
silently grow new shape surface in this PR.

Will delete or trim useAuth.ts entirely in PR 3 polish once App.tsx
migrates to import useAuthState directly.
The previous connectSSE silently fell back to ?token=<jwt> when the
sse-ticket endpoint was unavailable. Putting the raw JWT in a URL
leaks it into browser history, the back/forward cache, server access
logs, and the Referer header — all defeating the point of the
short-lived ticket.

Now:
- ticket exchange success → ?ticket=<ticket> (unchanged)
- ticket exchange failure in DEV → ?token=<jwt> (preserved as a local
  dev convenience against older server builds without /api/auth/sse-ticket)
- ticket exchange failure in production → throw

Logic split into a pure pickSseUrl helper so the env-gated branching is
testable without an EventSource. 5 new unit tests cover the matrix.
…esolves

The external-disconnect cleanup effect fired on every page load: Zustand
hydrates the persisted JWT before wallet-adapter's autoConnect resolves,
which means the first render sees connected=false + token!=null and
trips the clear-auth branch. The user gets bounced to the unauth state
for one render, then Phantom reconnects but the token is already gone.

Gate the clear behind a wasConnectedRef that flips true only after we've
actually seen connected:true. The effect now only fires on a true
connected → disconnected transition, which is the case the cleanup was
meant to catch (user clicks Disconnect in Phantom, locks wallet, etc).

Also bumps the e2e Playwright fixture from Zustand persist version 0 to
1 with expiresAt populated, so the test JWT survives the v0->v1 migrate
that was nuking it. mintAdminJwt now decodes the JWT exp claim and
returns it alongside the token. Without this, all 4 authenticated
e2e specs (auth-flow, chat, herald, squad) timed out waiting for admin
tabs that the auth-cleared store never rendered.

144/144 app tests green; new test pins the autoConnect-race fix.
@rz1989s rz1989s merged commit 6cd0f70 into main May 7, 2026
6 checks passed
@rz1989s rz1989s deleted the feat/authsync-architecture branch May 7, 2026 00:30
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