Skip to content

Comments

feat: sign in to access backend data with JWT#5

Merged
ical10 merged 12 commits intomainfrom
feat/sign-in-with-jwt
Dec 8, 2025
Merged

feat: sign in to access backend data with JWT#5
ical10 merged 12 commits intomainfrom
feat/sign-in-with-jwt

Conversation

@ical10
Copy link
Owner

@ical10 ical10 commented Dec 8, 2025

This PR includes:

  • Full sign-in flow (UI, backend / API, supabase) using a JWT as auth headers

Summary by CodeRabbit

  • New Features

    • Wallet-based sign-in (client) with persistent token storage and auth state stores
    • Server-side sign-in endpoint issuing JWTs and a global request hook that sets user context
    • Auth enforcement for creating, viewing, and modifying splits
  • Improvements

    • API calls include Authorization headers when a token is present
    • Address normalization, replay protection checks, and clearer error responses
    • Added type declarations for authenticated user shape
  • Dependencies

    • Added JWT library for token handling

✏️ Tip: You can customize this high-level summary in your review settings.

@ical10 ical10 self-assigned this Dec 8, 2025
@netlify
Copy link

netlify bot commented Dec 8, 2025

Deploy Preview for gnosis-split failed.

Name Link
🔨 Latest commit c9a1cd4
🔍 Latest deploy log https://app.netlify.com/projects/gnosis-split/deploys/6936ff4f2cab94000868de36

@coderabbitai
Copy link

coderabbitai bot commented Dec 8, 2025

Walkthrough

Implements JWT-based wallet authentication: client signing utilities and Svelte store; new server signin endpoint that issues JWTs; server handle hook that verifies Bearer tokens and populates locals.user; split API routes updated to require and authorize requests from locals.user; added jsonwebtoken deps and build secret.

Changes

Cohort / File(s) Summary
Dependencies
package.json
Added jsonwebtoken and @types/jsonwebtoken.
Type Definitions
src/app.d.ts
Declared App.Locals with user payload shape (sub, user_metadata.address, iat, exp) or null.
Server Hook
src/hooks.server.ts
New handle hook: parses Authorization: Bearer, verifies JWT (HS256) with SUPABASE_JWT_SECRET, sets event.locals.user or null, always calls resolve(event).
Auth API
src/routes/api/auth/signin/+server.ts
New POST handler: verifies wallet signature (viem), enforces 5-minute timestamp window, issues HS256 JWT (1d) with address in sub and user_metadata.
Protected Splits API
src/routes/api/splits/+server.ts, src/routes/api/splits/[id]/+server.ts
Handlers now read user address from locals.user, return 401/403 appropriately, normalize addresses via getAddress() (viem), enforce creator/participant permissions, and accept locals in signatures.
Client Auth Utilities
src/lib/auth.ts
Added signInWithWallet(address, config), getAuthToken(), and clearAuth() to sign messages, call /api/auth/signin, and manage token in localStorage.
Auth Store
src/lib/stores/auth.ts
New Svelte stores: isAuthenticated, isSigningIn, signInError; functions attemptSignIn(walletAddress) and checkAuthStatus(); auto-checks on load and subscribes to address changes.
API header helpers
src/lib/storage.ts, src/lib/supabase.ts
Added getAuthHeaders() / getHeaders() and inject Authorization & Content-Type headers into fetch calls when token present; improved non-OK response handling.
Layout import
src/routes/+layout.svelte
Imported $lib/stores/auth so layout observes auth store.
CI / Build
.github/workflows/netlify-deploy.yml
Added SUPABASE_JWT_SECRET to the build environment for the build step.

Sequence Diagram

sequenceDiagram
    participant User
    participant Browser as Client
    participant AuthStore
    participant SignInAPI as /api/auth/signin
    participant ServerHook as hooks.server
    participant SplitsAPI as /api/splits
    participant DB

    User->>Browser: connect wallet & sign message
    Browser->>SignInAPI: POST (message, signature, address, ts)
    SignInAPI->>SignInAPI: verify signature & timestamp
    SignInAPI->>SignInAPI: issue JWT (HS256, exp=1d)
    SignInAPI->>Browser: return token
    Browser->>AuthStore: store token, set isAuthenticated
    Browser->>Browser: save token to localStorage

    Browser->>SplitsAPI: GET /api/splits (Authorization: Bearer {token})
    SplitsAPI->>ServerHook: request enters (hook runs)
    ServerHook->>ServerHook: verify JWT, decode user
    ServerHook->>SplitsAPI: attach user to locals
    SplitsAPI->>SplitsAPI: authorize (creator/participant)
    SplitsAPI->>DB: query/modify splits
    DB->>SplitsAPI: return result
    SplitsAPI->>Browser: return data/status
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

  • Pay attention to:
    • src/hooks.server.ts — JWT verification, algorithm, secret usage, and error resilience.
    • src/routes/api/auth/signin/+server.ts — signature verification, timestamp replay protection, JWT claims and expiry.
    • src/routes/api/splits/... — authorization checks, address normalization (getAddress()), and correct HTTP status semantics.
    • src/lib/storage.ts / src/lib/supabase.ts — header composition and consistent error handling.
    • src/lib/stores/auth.ts — race conditions around wallet address changes and sign-in flow.

Possibly related PRs

Poem

🐰 I hopped to sign and did not stall,
A message signed — a token small.
I tuck it safe in local store,
I guard the splits and hop some more. ✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the main objective of the changeset: implementing JWT-based sign-in to secure backend data access.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/sign-in-with-jwt

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/lib/storage.ts (1)

45-57: Error is thrown then caught and swallowed.

At line 51, you throw an error on non-OK responses, but the outer catch block (lines 54-57) catches it and returns an empty array. This inconsistency means errors are logged but callers won't know authentication failed vs. a network error.

Consider distinguishing auth errors from other failures:

       if (!response.ok) {
         const errorData = await response.json().catch(() => ({}));
         console.error('API error:', response.status, errorData);
+        if (response.status === 401) {
+          // Could trigger re-auth or clear invalid token
+        }
         throw new Error(`Failed to fetch splits: ${response.status}`);
       }
🧹 Nitpick comments (8)
src/hooks.server.ts (1)

11-15: Consider adding proper typing and error logging.

The as any cast loses type safety. Also, silently swallowing JWT verification errors may complicate debugging authentication issues in production.

-      const decodedPayload = jwt.verify(token, SUPABASE_JWT_SECRET, { algorithms: ['HS256'] }) as any;
+      const decodedPayload = jwt.verify(token, SUPABASE_JWT_SECRET, { algorithms: ['HS256'] }) as App.Locals['user'];
       event.locals.user = decodedPayload;
     } catch (error) {
+      console.warn('JWT verification failed:', error instanceof Error ? error.message : 'Unknown error');
       event.locals.user = null;
package.json (1)

54-54: Move @types/jsonwebtoken to devDependencies.

Type definitions are only needed at build time and shouldn't be shipped to production.

Move the dependency:

 "devDependencies": {
+  "@types/jsonwebtoken": "^9.0.10",
   "@eslint/compat": "^1.4.0",
   ...
 },
 "dependencies": {
-  "@types/jsonwebtoken": "^9.0.10",
   "@reown/appkit": "^1.8.14",
src/lib/supabase.ts (1)

17-17: Content-Type header is unnecessary for GET requests.

GET requests typically don't have a body, so Content-Type: application/json is redundant here. While harmless, it could be removed for cleanliness.

src/routes/+layout.svelte (1)

6-6: Add a comment to clarify the side-effect import.

This bare import initializes the auth store and sets up authentication checks at app load. A brief comment will clarify intent for future maintainers.

-  import '$lib/stores/auth';
+  // Initialize auth store and check authentication status on app load
+  import '$lib/stores/auth';
src/lib/auth.ts (3)

6-6: Type config parameter properly instead of using any.

The config parameter has type any, which loses type safety. Consider importing and using the proper Wagmi config type.

+import type { Config } from '@wagmi/core';
+
-export async function signInWithWallet(address: Address, config: any): Promise<string> {
+export async function signInWithWallet(address: Address, config: Config): Promise<string> {

42-44: Remove redundant try-catch that only re-throws.

The catch block at lines 42-44 catches the error and immediately re-throws it without any transformation or logging, which is redundant.

-  } catch (error) {
-    throw error;
-  }
+  }

37-41: Missing SSR guard before accessing localStorage.

Unlike getAuthToken and clearAuth, signInWithWallet doesn't check for typeof window === 'undefined' before accessing localStorage. While this function is unlikely to be called server-side, adding the guard would be consistent.

     const { token } = await response.json();
 
+    if (typeof window !== 'undefined') {
       localStorage.setItem(AUTH_TOKEN_KEY, token);
+    }
 
     return token;
src/routes/api/splits/[id]/+server.ts (1)

28-37: Extract duplicated authorization logic into a helper function.

The same authorization check pattern (fetch split, checksum address, check isCreator/isParticipant) is repeated in all four handlers. Consider extracting to a shared utility.

async function checkAuthorization(
  supabase: SupabaseClient, 
  splitId: string, 
  userAddress: string
): Promise<{ authorized: boolean; isCreator: boolean; split?: SplitData; error?: Response }> {
  const checksumUserAddress = getAddress(userAddress);
  const { data, error } = await supabase
    .from('splits')
    .select('*')
    .eq('id', splitId)
    .single();

  if (error || !data) {
    return { authorized: false, isCreator: false, error: json({ error: 'Split not found' }, { status: 404 }) };
  }

  const isCreator = data.payer_address === checksumUserAddress;
  const participants = data.participants as unknown as Participant[];
  const isParticipant = participants?.some(p => {
    try { return getAddress(p.address) === checksumUserAddress; } 
    catch { return false; }
  });

  return { authorized: isCreator || isParticipant, isCreator, split: data };
}

Also applies to: 89-98, 167-176, 216-225

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1efbca0 and 03e365b.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (11)
  • package.json (1 hunks)
  • src/app.d.ts (1 hunks)
  • src/hooks.server.ts (1 hunks)
  • src/lib/auth.ts (1 hunks)
  • src/lib/storage.ts (7 hunks)
  • src/lib/stores/auth.ts (1 hunks)
  • src/lib/supabase.ts (2 hunks)
  • src/routes/+layout.svelte (1 hunks)
  • src/routes/api/auth/signin/+server.ts (1 hunks)
  • src/routes/api/splits/+server.ts (3 hunks)
  • src/routes/api/splits/[id]/+server.ts (7 hunks)
🧰 Additional context used
🧬 Code graph analysis (6)
src/lib/supabase.ts (1)
src/lib/auth.ts (1)
  • getAuthToken (47-50)
src/lib/auth.ts (1)
src/lib/stores/wallet.ts (1)
  • address (41-41)
src/routes/api/splits/+server.ts (3)
src/routes/api/splits/[id]/+server.ts (1)
  • GET (8-56)
src/lib/server/supabase.ts (1)
  • getSupabase (7-25)
src/routes/api/auth/signin/+server.ts (1)
  • POST (10-54)
src/lib/stores/auth.ts (2)
src/lib/auth.ts (2)
  • signInWithWallet (6-45)
  • getAuthToken (47-50)
src/lib/stores/wallet.ts (1)
  • address (41-41)
src/routes/api/splits/[id]/+server.ts (3)
src/routes/api/splits/+server.ts (1)
  • GET (8-56)
src/lib/types.ts (1)
  • Participant (34-38)
src/lib/server/supabase.ts (1)
  • getSupabase (7-25)
src/lib/storage.ts (1)
src/lib/auth.ts (1)
  • getAuthToken (47-50)
🪛 GitHub Actions: Netlify Deploy
src/routes/api/auth/signin/+server.ts

[error] 5-5: "SUPABASE_JWT_SECRET" is not exported by "virtual:env/static/private", imported by "src/routes/api/auth/signin/+server.ts".

🔇 Additional comments (5)
src/app.d.ts (1)

6-15: LGTM!

The Locals interface correctly defines the JWT payload structure with proper nullable typing for unauthenticated requests. The inclusion of iat and exp at the top level aligns with standard JWT claims.

src/hooks.server.ts (1)

5-21: Protected routes already enforce authentication with 401 responses.

The current implementation correctly delegates authorization to individual routes, and the protected API endpoints (/api/splits/*) all validate locals.user and return 401 status with "Unauthorized" error for missing or invalid tokens. The pattern is working as intended.

package.json (1)

56-56: No action required—jsonwebtoken 9.0.3 has no known security vulnerabilities. Previous CVEs (CVE-2022-23529, CVE-2022-23541, etc.) were fixed in the v9.0.0 release; this version constraint is secure.

src/lib/storage.ts (1)

9-25: LGTM!

The getAuthHeaders() and getHeaders() utility functions correctly compose authorization headers. The pattern of returning an empty object when no token exists allows unauthenticated requests to proceed gracefully.

src/routes/api/splits/+server.ts (1)

8-56: LGTM on authentication integration.

The GET handler correctly derives the user address from locals.user populated by the auth hook, returns 401 for unauthenticated requests, and properly filters splits by the authenticated user's address.

@github-actions
Copy link

github-actions bot commented Dec 8, 2025

@github-actions github-actions bot temporarily deployed to pull request December 8, 2025 16:07 Inactive
@github-actions
Copy link

github-actions bot commented Dec 8, 2025

🚀 Deployed on https://gnosis-split.netlify.app
Commit: 45fee3a

@github-actions
Copy link

github-actions bot commented Dec 8, 2025

🚀 Deployed on https://gnosis-split.netlify.app
Commit: 0c3380e

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (3)
src/routes/api/splits/[id]/+server.ts (2)

58-100: Restrict PUT updates so participants cannot change core split fields (including payer_address).

The PUT handler currently treats creators and participants the same:

  • If the user is either isCreator or isParticipant, they can update all fields, including payer_address, description, totalAmount, and date.

That lets any participant reassign payer_address to themselves (or someone else), effectively taking ownership of the split and gaining creator‑level privileges (including delete), which is a serious authorization bug.

A straightforward fix is to reserve the full update for creators only and route participant‑level changes through the more specific PATCH endpoint:

-    const isCreator = splitData.payer_address === checksumUserAddress;
+    const isCreator = splitData.payer_address === checksumUserAddress;
@@
-    if (!isCreator && !isParticipant) {
-      return json({ error: 'Forbidden' }, { status: 403 });
-    }
+    if (!isCreator) {
+      return json({ error: 'Forbidden' }, { status: 403 });
+    }

If you do want participants to update some fields via PUT, introduce a role‑based whitelist and reject payloads containing forbidden keys for non‑creators rather than applying them blindly.


28-33: Guard against getAddress(p.address) throwing for malformed participant data.

All three participant checks:

  • GET: participants?.some((p) => getAddress(p.address) === checksumUserAddress)
  • PUT: same pattern on splitData.participants
  • PATCH: same pattern on splitData.participants

will throw if any stored participant address is malformed, turning a data issue into a 500.

A minimal defensive pattern:

-    const isParticipant = participants?.some(
-      (p: Participant) => getAddress(p.address) === checksumUserAddress
-    );
+    const isParticipant = participants?.some((p: Participant) => {
+      try {
+        return getAddress(p.address) === checksumUserAddress;
+      } catch {
+        return false;
+      }
+    });

Apply the same change in the PUT and PATCH handlers where this check is duplicated.

According to the viem documentation, does `getAddress` throw an exception when called with an invalid Ethereum address (e.g., bad checksum or wrong format), or does it return a falsy value?

Also applies to: 92-94, 212-215

src/routes/api/splits/+server.ts (1)

10-23: Defensively handle getAddress() failures for user and payer addresses.

Both usages:

  • const checksumAddress = getAddress(userAddress);
  • if (getAddress(split.payerAddress) !== getAddress(userAddress)) { ... }

will throw if either userAddress (from the token) or split.payerAddress (from the request body) is malformed. Today that ends up as a generic 500 rather than a clear client error.

Consider wrapping these in a small guard and returning a 400 instead:

-    const checksumAddress = getAddress(userAddress);
+    let checksumAddress: string;
+    try {
+      checksumAddress = getAddress(userAddress);
+    } catch {
+      return json({ error: 'Invalid user address' }, { status: 400 });
+    }

and for the POST check:

-    if (getAddress(split.payerAddress) !== getAddress(userAddress)) {
+    let payerChecksum: string;
+    let userChecksum: string;
+    try {
+      payerChecksum = getAddress(split.payerAddress);
+      userChecksum = getAddress(userAddress);
+    } catch {
+      return json({ error: 'Invalid address in request' }, { status: 400 });
+    }
+
+    if (payerChecksum !== userChecksum) {
       return json({ error: 'Forbidden: can only create splits for yourself' }, { status: 403 });
     }

This keeps authorization logic while avoiding treating bad input as a server error.

In viem, what does `getAddress` do when passed an invalid or malformed Ethereum address string (e.g., wrong length or non-hex)? Does it throw an error or return a fallback?

Also applies to: 77-79

🧹 Nitpick comments (1)
src/lib/stores/auth.ts (1)

38-64: Guard wallet‑driven auth side effects to the browser and avoid double clears.

Right now the address.subscribe side effects (calling clearAuth() and attemptSignIn) can run during SSR if the wallet store ever emits a non‑null value server‑side, and clearAuth() is invoked twice when transitioning from a real address to a falsy $address.

Consider:

  • Wrapping the subscription in the same browser guard as checkAuthStatus():
-if (typeof window !== 'undefined') {
-  checkAuthStatus();
-}
-
-address.subscribe(async ($address) => {
+if (typeof window !== 'undefined') {
+  checkAuthStatus();
+
+  address.subscribe(async ($address) => {
     if (lastAddress && lastAddress !== $address) {
       clearAuth();
       isAuthenticated.set(false);
       signInError.set(null);
     }
 
     if (!$address) {
       clearAuth();
       isAuthenticated.set(false);
       signInError.set(null);
       lastAddress = null;
       return;
     }
 
     lastAddress = $address;
 
     const currentlyAuthenticated = get(isAuthenticated);
 
     if (!currentlyAuthenticated) {
       await attemptSignIn($address);
     }
-});
+  });
+}
  • (Optional) Removing the first clearAuth() branch for the “address becomes falsy” case, since the !$address block already clears state.
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b5d80e6 and c9a1cd4.

📒 Files selected for processing (6)
  • src/lib/storage.ts (7 hunks)
  • src/lib/stores/auth.ts (1 hunks)
  • src/lib/supabase.ts (2 hunks)
  • src/routes/api/auth/signin/+server.ts (1 hunks)
  • src/routes/api/splits/+server.ts (3 hunks)
  • src/routes/api/splits/[id]/+server.ts (7 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/lib/storage.ts
🧰 Additional context used
🧬 Code graph analysis (4)
src/routes/api/auth/signin/+server.ts (2)
src/routes/api/splits/+server.ts (1)
  • POST (58-118)
src/lib/stores/wallet.ts (1)
  • address (41-41)
src/lib/supabase.ts (1)
src/lib/storage.ts (1)
  • getHeaders (19-25)
src/lib/stores/auth.ts (2)
src/lib/auth.ts (3)
  • signInWithWallet (6-45)
  • getAuthToken (47-50)
  • clearAuth (52-55)
src/lib/stores/wallet.ts (1)
  • address (41-41)
src/routes/api/splits/+server.ts (3)
src/routes/api/splits/[id]/+server.ts (1)
  • GET (8-56)
src/lib/server/supabase.ts (1)
  • getSupabase (7-25)
src/routes/api/auth/signin/+server.ts (1)
  • POST (40-83)
🔇 Additional comments (2)
src/lib/supabase.ts (1)

3-15: Using getHeaders() here is a solid reuse of the shared auth/header helper.

This keeps header construction consistent with the rest of the API calls and removes duplication from this store loader.

src/routes/api/auth/signin/+server.ts (1)

11-82: Signin flow now correctly validates message, timestamp, and signature before issuing tokens.

The added validateMessage() plus checksum normalization of the address and explicit error responses make this handler robust; no further issues stand out.

@ical10 ical10 merged commit 2f72fc8 into main Dec 8, 2025
3 of 7 checks passed
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