feat(app): AuthSyncProvider FE architecture (PR 1/2 of Phase 4a auth fix)#175
Merged
feat(app): AuthSyncProvider FE architecture (PR 1/2 of Phase 4a auth fix)#175
Conversation
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.
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.
Summary
PR 1 of 2 for the Phase 4a auth + security proper-fix. This PR is the frontend half: a new
AuthSyncProviderthat 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 legacyuseAuth+useAppStore.tokenpair and onto a singleuseAuthState()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 directprocess.env.SOLANA_NETWORKreads, and/pay/:id/confirmfail-closed.Spec + plan: see PR #174 (docs).
Commits (19 total)
What this PR fixes
From the QA findings consolidated in the spec:
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
Out of scope (deferred)