Skip to content

feat(dapp): authenticate read endpoints with SIWE-issued JWT#98

Open
sadiq1971 wants to merge 2 commits into
mainfrom
feat/dapp-read-auth-jwt
Open

feat(dapp): authenticate read endpoints with SIWE-issued JWT#98
sadiq1971 wants to merge 2 commits into
mainfrom
feat/dapp-read-auth-jwt

Conversation

@sadiq1971

@sadiq1971 sadiq1971 commented Jul 13, 2026

Copy link
Copy Markdown
Member

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 /profile are now called with a SIWE-issued bearer token instead of the removed X-Signature/X-Message headers and ?address= query parameter.

What changed

New lib/auth.ts — the SIWE (EIP-4361) session layer:

  • ensureAuthToken: fetches a nonce from GET /auth/nonce, builds the EIP-4361 message (EIP-55 checksummed address, as siwe-go requires), signs it with personal_sign, and exchanges it at POST /auth/login for the JWT. Tokens are cached in sessionStorage per address and refreshed 60s before expires_at.
  • Single-flight per (middleware, address): dashboard pages fire 3–4 read calls at once; they share one login, so the user sees exactly one MetaMask prompt.
  • authorizedFetch: attaches Authorization: Bearer on reads; on a 401 (token rejected early / signing key rotated) it drops the session and re-logins once.
  • Auth-disabled fallback: when the middleware has no auth config block the /auth routes 404, and reads fall back to the legacy ?address= parameter — so the dapp works against both middleware versions during rollout.
  • interactive: false mode 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 old login:<addr>:<ts> session signature and its expired-retry dance are gone. getUser now triggers the SIWE login when needed. Since /auth/login rejects unregistered addresses (address is not registered, 401), that rejection — surfaced as NotRegisteredError — 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 auth config or login is rejected:

  • domain/uri default to the dapp's own origin, overridable with VITE_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.example and docs/testing-with-middleware.md.
  • chain_id is fetched from the middleware's own /eth RPC, 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.

⚠️ Deploy order: the middleware (#354, or at least its auth-disabled build) must deploy before this dapp. Against a pre-#354 middleware, /profile still requires the removed X-Signature/X-Message headers, and this dapp can no longer send them — connect would fail for everyone. The legacy fallback here targets #354's auth-disabled mode, not the pre-#354 API.

Deploy note: on deployed networks no SIWE env overrides are needed — the middleware's auth configs pin domain/uri to the dapp origin (evm-middleware.chainsafe.io on mainnet, dapp-dev1.01.chainsafe.dev on devnet), which is exactly what the dapp sends by default. The VITE_SIWE_DOMAIN/VITE_SIWE_URI overrides are only for local dev, where the docker stack expects localhost / http://localhost while vite serves on localhost:3000.

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-interactive SessionExpiredError, 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 divergent isUserRejection helpers. npm test, npm run lint, npm run format:check, and npm run build all pass.

🤖 Generated with Claude Code

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

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/dapp/src/lib/auth.ts Outdated
});
if (!res.ok) {
const msg = await errorMessage(res);
if (res.status === 401 && msg.includes("not registered")) throw new NotRegisteredError();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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().

Suggested change
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
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