feat(dapp): authenticate read endpoints with SIWE-issued JWT#98
feat(dapp): authenticate read endpoints with SIWE-issued JWT#98sadiq1971 wants to merge 2 commits into
Conversation
The middleware now gates its read endpoints (/profile and the transfer
listings) behind a SIWE login that issues a short-lived RS256 JWT
(canton-middleware#352-354). Replace the per-request X-Signature/X-Message
headers and ?address= query parameters with that flow:
- lib/auth.ts: EIP-4361 message construction, /auth/nonce + /auth/login
exchange, per-address token cache in sessionStorage, single-flight login
(one MetaMask prompt even when a dashboard page fires several reads),
one-shot 401 re-login, and a legacy ?address= fallback when the
middleware runs without an auth config block (/auth routes 404)
- lib/session.ts: sessions now store {token, expiresAt} instead of the
signed login message; old-format entries read as no session
- getUser and the transfer list calls go through authorizedFetch; the
connect flow routes to registration on the login service's
'address is not registered' rejection since unregistered addresses can
no longer reach /profile
- SIWE domain/uri default to the dapp origin, overridable via
VITE_SIWE_DOMAIN / VITE_SIWE_URI (the local docker stack expects
localhost / http://localhost); chain id comes from the middleware's own
/eth RPC, which its auth config mirrors on every network
There was a problem hiding this comment.
Code Review
This pull request implements Sign-In with Ethereum (SIWE, EIP-4361) to authenticate read-endpoint requests against the middleware. It introduces a new auth module to manage nonces, signatures, and JWT session tokens, and updates API calls (profile and transfer listings) to use bearer tokens with a fallback to the legacy query parameter. Feedback is provided regarding a case-sensitive error message check in the login flow that could be made more robust by using a case-insensitive comparison.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| }); | ||
| if (!res.ok) { | ||
| const msg = await errorMessage(res); | ||
| if (res.status === 401 && msg.includes("not registered")) throw new NotRegisteredError(); |
There was a problem hiding this comment.
The error message check msg.includes("not registered") is case-sensitive. If the server's error message capitalization changes (e.g., to "Not registered" or "Address is not registered"), this check will fail to identify the unregistered state, leading to unexpected behavior. It is safer to perform a case-insensitive check using .toLowerCase().
| if (res.status === 401 && msg.includes("not registered")) throw new NotRegisteredError(); | |
| if (res.status === 401 && msg.toLowerCase().includes("not registered")) throw new NotRegisteredError(); |
- authorizedFetch: on 401, only drop the session if it still holds the token this request used — a concurrent 401's re-login may have already stored a fresh token the retry should reuse instead of re-prompting - ensureAuthToken: re-store the auth-disabled marker session when serving from the in-memory map, so auto-reconnect survives a disconnect/ reconnect cycle against a middleware without /auth routes - registration done page: run the SIWE sign-in on the 'Go to dashboard' click (registration is self-authenticating, so no JWT exists yet) instead of letting the dashboard's first reads pop a surprise prompt; a dismissed prompt stays on the page so the user can click again - login: fetch the chain id in parallel with the nonce so the nonce spends less of its server-side TTL before the user signs; match the 'not registered' rejection case-insensitively - isUserRejection: adopt the broader semantics of the previously duplicated helper in useMetaMaskImport (code 4001 or /reject/i message) and reuse it there
Dapp counterpart to the middleware's read-API authentication (ChainSafe/canton-middleware#352 / #353 / #354). The transfer listings (
/api/v2/transfer/incoming|outgoing|completed) and/profileare now called with a SIWE-issued bearer token instead of the removedX-Signature/X-Messageheaders and?address=query parameter.What changed
New
lib/auth.ts— the SIWE (EIP-4361) session layer:ensureAuthToken: fetches a nonce fromGET /auth/nonce, builds the EIP-4361 message (EIP-55 checksummed address, assiwe-gorequires), signs it withpersonal_sign, and exchanges it atPOST /auth/loginfor the JWT. Tokens are cached insessionStorageper address and refreshed 60s beforeexpires_at.authorizedFetch: attachesAuthorization: Beareron reads; on a 401 (token rejected early / signing key rotated) it drops the session and re-logins once.authconfig block the/authroutes 404, and reads fall back to the legacy?address=parameter — so the dapp works against both middleware versions during rollout.interactive: falsemode for the refresh auto-reconnect path: a page load never pops a signature prompt; an expired session just lands back on the connect button (same behavior as before).Connect flow (
App.tsx): the oldlogin:<addr>:<ts>session signature and its expired-retry dance are gone.getUsernow triggers the SIWE login when needed. Since/auth/loginrejects unregistered addresses (address is not registered, 401), that rejection — surfaced asNotRegisteredError— routes to registration where a null profile used to. Prompt count is unchanged: one signature on connect, one on registration.SIWE message parameters must match the middleware's
authconfig or login is rejected:domain/uridefault to the dapp's own origin, overridable withVITE_SIWE_DOMAIN/VITE_SIWE_URI(plumbed through the Dockerfile/entrypoint). The local docker stack needs the overrides (localhost/http://localhost) since vite serves on port 3000 — documented in.env.exampleanddocs/testing-with-middleware.md.chain_idis fetched from the middleware's own/ethRPC, which the auth config mirrors on every deployed network — no new config needed.Sessions (
lib/session.ts) now store{token, expiresAt}; leftover pre-JWT entries parse as "no session", so upgraded users just reconnect once.Token expiry mid-session (TTL 6h) surfaces as one re-login prompt on the next read, per the no-refresh-token design in canton-middleware#354.
Tests
packages/dapp/src/lib/auth.test.ts(first dapp unit tests): message layout, bearer attachment + token reuse, concurrent-read single-flight, auth-disabled fallback, 401 re-login, expiry-margin refresh, non-interactiveSessionExpiredError,NotRegisteredError, and single-flight cleanup after a failed login. A workflow-backed self-review added fixes (and tests) for: the concurrent-401 race that could wipe a freshly re-issued token, the auth-disabled marker not being re-stored after disconnect, the post-registration dashboard entry popping an unplanned signature prompt (sign-in now runs on the “Go to dashboard” click), serialized nonce/chain-id fetches aging the nonce, case-sensitive matching of the “not registered” rejection, and two divergentisUserRejectionhelpers.npm test,npm run lint,npm run format:check, andnpm run buildall pass.🤖 Generated with Claude Code