From 88ff441860489ca8338e80410b2f38d32fd3747f Mon Sep 17 00:00:00 2001 From: EmmanuelOchaje Date: Sun, 18 Oct 2026 10:25:18 +0100 Subject: [PATCH] feat: implement wallet auth lifecycle and cross-tab sync Issue #27 - Hardware wallet support: - Document Ledger/Trezor integration via StellarWalletsKit - All wallet modules enabled by default (Freighter, WalletConnect, Ledger, Trezor) - Add comprehensive troubleshooting guide for hardware wallet setup Issue #28 - Cross-tab state sync: - Add cross-tab-sync.ts for BroadcastChannel with storage event fallback - Sync wallet connection, disconnection, and session changes across tabs - Prevent session hijacking when same wallet used in multiple tabs - Integrate sync listener in WalletProvider Issue #29 - JWT lifecycle management: - Add jwt-manager.ts for expiry checking and single-flight re-auth - decodeJWT() safely parses JWT payload without verification - isTokenExpired() checks if token is expired (with 30s buffer) - getTokenTTL() returns milliseconds until expiry - Single-flight re-auth prevents thundering herd on 401 errors - ensureAuthWithSingleFlight() deduplicates concurrent re-auth attempts Issue #30 - SEP-10 validation: - Add sep10-validator.ts for pre-signing challenge validation - validateSEP10Challenge() checks XDR format, network, expiry, signatures - extractChallengeToken() retrieves token from ManageData operation - signInWithWallet() now validates before signing (prevents accidents) Enhanced API client with isAuthError() to detect 401/TOKEN_EXPIRED conditions. Updated auth.ts to integrate all new modules. Updated WalletProvider to use cross-tab sync. Closes #27 Closes #28 Closes #29 Closes #30 --- docs/hardware-wallets.md | 110 ++++++++++++++++++++++++++++ lib/api/auth.ts | 78 +++++++++++++++++++- lib/api/client.ts | 11 +++ lib/api/cross-tab-sync.ts | 113 +++++++++++++++++++++++++++++ lib/api/jwt-manager.ts | 95 ++++++++++++++++++++++++ lib/stellar/WalletProvider.tsx | 34 +++++++++ lib/stellar/sep10-validator.ts | 129 +++++++++++++++++++++++++++++++++ 7 files changed, 568 insertions(+), 2 deletions(-) create mode 100644 docs/hardware-wallets.md create mode 100644 lib/api/cross-tab-sync.ts create mode 100644 lib/api/jwt-manager.ts create mode 100644 lib/stellar/sep10-validator.ts diff --git a/docs/hardware-wallets.md b/docs/hardware-wallets.md new file mode 100644 index 0000000..94711d5 --- /dev/null +++ b/docs/hardware-wallets.md @@ -0,0 +1,110 @@ +# Hardware Wallet Support (Ledger, Trezor) + +OracleDesk-Frontend supports hardware wallets through the Stellar Wallets Kit, which provides integration with Ledger and Trezor devices. + +## Supported Devices + +- **Ledger Nano S/S Plus/X**: USB connection via Ledger Live or ledger.com browser extension +- **Ledger Stax**: Bluetooth connection via Ledger Live +- **Trezor Model One/T**: USB connection via Trezor browser extension + +## Enabled Modules + +The app includes all wallet modules via `@creit.tech/stellar-wallets-kit/modules/utils`: + +```typescript +import { defaultModules } from "@creit.tech/stellar-wallets-kit/modules/utils"; +// Includes: Freighter, WalletConnect, Ledger, Trezor, and others +``` + +## Browser Support + +| Device | Chrome | Firefox | Safari | Edge | +|---|---|---|---|---| +| Ledger (USB HID) | ✅ | ✅ | ✅ (11+) | ✅ | +| Trezor | ✅ | ✅ | ⚠️ Limited | ✅ | + +USB HID (required for direct USB connection) is supported in all modern browsers except Safari (uses WebHID). + +## Using Hardware Wallets + +### 1. Ledger + +**Via browser extension (recommended):** +- Install [Ledger Live](https://www.ledgerwallet.com/) browser extension +- Or use [Ledger Stellar App](https://ledger.com/pages/ledger-stellar) +- Connect your device +- Open OracleDesk + +**Via USB:** +- Connect Ledger device +- Open OracleDesk in a browser that supports WebHID (all except Safari) +- Select Ledger from wallet picker + +**Transaction signing:** +1. OracleDesk displays "Confirm on device" +2. Review transaction on Ledger screen +3. Press right button to approve (or left to reject) + +### 2. Trezor + +**Via browser extension:** +- Install [Trezor Bridge](https://wallet.trezor.io/) or use browser extension +- Connect your device +- Open OracleDesk + +**Via USB:** +- Connect Trezor device +- Open OracleDesk in a browser that supports WebHID +- Select Trezor from wallet picker + +**Transaction signing:** +1. A browser popup window appears +2. Review transaction details +3. Click "Confirm" on device (or reject) + +## SEP-10 Challenge Signing + +Hardware wallets sign SEP-10 challenge transactions before the user can trade: + +1. User clicks "Connect Wallet" → selects hardware wallet +2. OracleDesk requests a SEP-10 challenge from the backend +3. Challenge is validated before signing (see `lib/stellar/sep10-validator.ts`) +4. User confirms signature on device +5. Backend verifies signature and returns JWT + +This prevents the user from accidentally signing arbitrary transactions. + +## Troubleshooting + +### "Device not found" +- Ensure device is connected and unlocked +- Try refreshing the page +- Check browser console for errors +- For Ledger: open Ledger Live and switch to browser mode + +### "User rejected transaction" +- User cancelled on the device +- Try again or use a different account + +### "Signature verification failed" +- Device may have outdated firmware; update via Ledger Live or Trezor Wallet +- Try a different account if available + +### Safari limitations +- Safari doesn't support WebHID (required for direct USB connection) +- Use browser extensions (Ledger Live) instead +- Or switch to Chrome/Firefox/Edge + +## Development + +To test hardware wallet support: + +```bash +npm run dev +# Open http://localhost:3000 +# Connect any supported hardware wallet +# Try signing a transaction (trades auto-redirect to /markets after login) +``` + +The `lib/stellar/WalletProvider.tsx` initializes the Stellar Wallets Kit and handles wallet connection, disconnection, and signing. diff --git a/lib/api/auth.ts b/lib/api/auth.ts index 5a6bdf0..25defb3 100644 --- a/lib/api/auth.ts +++ b/lib/api/auth.ts @@ -1,4 +1,7 @@ -import { apiClient, clearAuthSession, getStoredSessionWallet, getStoredToken, storeAuthSession } from "./client"; +import { apiClient, clearAuthSession, getStoredSessionWallet, getStoredToken, storeAuthSession, isAuthError } from "./client"; +import { validateSEP10Challenge, type SEP10ValidationError } from "@/lib/stellar/sep10-validator"; +import { isTokenExpired, getTokenTTL, getReAuthPromise, setReAuthPromise, isReAuthInFlight, waitForReAuth } from "./jwt-manager"; +import { broadcastSessionChange } from "./cross-tab-sync"; export interface AuthSession { token: string; @@ -21,21 +24,92 @@ export async function requestChallenge(address: string) { export async function verifyChallenge(address: string, signed: string) { const { data } = await apiClient.post("/auth/verify", { address, signed }); storeAuthSession(data); + broadcastSessionChange(data.token, data.walletAddress); return data; } +/** + * Validate SEP-10 challenge before signing. + * Throws if the challenge is invalid or expired. + */ +export function validateChallenge(challenge: AuthChallenge): SEP10ValidationError | null { + return validateSEP10Challenge(challenge, challenge.networkPassphrase); +} + /** * SEP-10 style login: the backend issues a challenge transaction, the wallet * signs it (signTransaction works in every Stellar wallet), the backend * verifies the signature and returns a JWT. + * + * Validates the challenge before signing to prevent signing malformed transactions. */ export async function signInWithWallet( address: string, signTransaction: (xdr: string, networkPassphrase: string) => Promise, ): Promise { const challenge = await requestChallenge(address); + + // Validate challenge before signing + const validationError = validateChallenge(challenge); + if (validationError) { + throw new Error(`Invalid SEP-10 challenge: ${validationError.message}`); + } + const signed = await signTransaction(challenge.transaction, challenge.networkPassphrase); return verifyChallenge(address, signed); } -export { clearAuthSession, getStoredSessionWallet, getStoredToken }; +/** + * Check if stored JWT is expired and needs refresh. + * Useful for preemptive re-auth before making API calls. + */ +export function isStoredTokenExpired(): boolean { + const token = getStoredToken(); + return !token || isTokenExpired(token); +} + +/** + * Get TTL of stored JWT (time until expiry in milliseconds). + * Returns 0 if token is invalid or expired. + */ +export function getStoredTokenTTL(): number { + const token = getStoredToken(); + return token ? getTokenTTL(token) : 0; +} + +/** + * Single-flight re-authentication: ensures only one re-auth request is in flight. + * Subsequent calls wait for the first one to complete instead of spawning new requests. + * + * This prevents the "thundering herd" problem when many API calls fail with 401 + * simultaneously and all try to re-auth at once. + */ +export async function ensureAuthWithSingleFlight( + address: string, + signTransaction: (xdr: string, networkPassphrase: string) => Promise, +): Promise { + // Check if re-auth is already in flight + if (isReAuthInFlight()) { + await waitForReAuth(); + // After waiting, return the current session (it may still fail if re-auth failed) + const token = getStoredToken(); + const wallet = getStoredSessionWallet(); + if (!token || !wallet) throw new Error("Re-authentication failed"); + return { token, userId: "", walletAddress: wallet }; + } + + // Start new re-auth attempt + const reAuthPromise = signInWithWallet(address, signTransaction); + setReAuthPromise(reAuthPromise); + + try { + const session = await reAuthPromise; + setReAuthPromise(null); + return session; + } catch (error) { + setReAuthPromise(null); + throw error; + } +} + +export { clearAuthSession, getStoredSessionWallet, getStoredToken, isAuthError }; diff --git a/lib/api/client.ts b/lib/api/client.ts index 5a5cb6a..7dafe9b 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -138,6 +138,17 @@ async function request( }; } +/** + * Enhanced error handling with JWT expiry detection. + * Returns true if the error indicates an expired or invalid token that needs re-auth. + */ +export function isAuthError(error: unknown): boolean { + if (error instanceof ApiError) { + return error.code === "TOKEN_EXPIRED" || error.code === "UNAUTHORIZED" || error.status === 401; + } + return false; +} + export const apiClient = { get(path: string, params?: QueryParams) { return request(path, { method: "GET" }, params); diff --git a/lib/api/cross-tab-sync.ts b/lib/api/cross-tab-sync.ts new file mode 100644 index 0000000..08d5273 --- /dev/null +++ b/lib/api/cross-tab-sync.ts @@ -0,0 +1,113 @@ +/** + * Cross-tab synchronization for wallet and session state. + * Uses BroadcastChannel for real-time sync and storage events as fallback. + */ + +export interface SyncMessage { + type: "auth_session_changed" | "wallet_disconnected" | "wallet_changed"; + address?: string | null; + token?: string | null; + timestamp: number; +} + +/** + * Create a BroadcastChannel for cross-tab sync, with graceful fallback to storage events. + */ +export function createCrossTabSync(onMessage: (msg: SyncMessage) => void) { + const unsubscribe: Array<() => void> = []; + + // Try to use BroadcastChannel (not supported in all browsers) + let channel: BroadcastChannel | null = null; + try { + channel = new BroadcastChannel("oracledesk-wallet-sync"); + channel.addEventListener("message", (event) => { + onMessage(event.data as SyncMessage); + }); + unsubscribe.push(() => { + channel?.close(); + }); + } catch { + // BroadcastChannel not available, fall back to storage events + } + + // Fallback: listen for storage events from other tabs + const handleStorageChange = (event: StorageEvent) => { + if (event.key === "oracledesk_wallet") { + onMessage({ + type: "wallet_changed", + address: event.newValue, + timestamp: Date.now(), + }); + } else if (event.key === "oracledesk_token") { + onMessage({ + type: "auth_session_changed", + token: event.newValue || undefined, + timestamp: Date.now(), + }); + } + }; + + if (!channel) { + window.addEventListener("storage", handleStorageChange); + unsubscribe.push(() => { + window.removeEventListener("storage", handleStorageChange); + }); + } + + return { + /** + * Broadcast a message to other tabs. + */ + broadcast(msg: SyncMessage) { + if (channel) { + channel.postMessage(msg); + } + // Storage events are automatic when localStorage changes + }, + + /** + * Stop listening for sync messages. + */ + unsubscribe: () => { + unsubscribe.forEach((fn) => fn()); + }, + }; +} + +/** + * Broadcast session change to other tabs. + */ +export function broadcastSessionChange(token: string | null, address: string | null) { + if (typeof window === "undefined") return; + + try { + const channel = new BroadcastChannel("oracledesk-wallet-sync"); + channel.postMessage({ + type: "auth_session_changed", + token, + address, + timestamp: Date.now(), + } as SyncMessage); + channel.close(); + } catch { + // BroadcastChannel not available; storage events will handle it + } +} + +/** + * Broadcast wallet disconnection to other tabs. + */ +export function broadcastWalletDisconnect() { + if (typeof window === "undefined") return; + + try { + const channel = new BroadcastChannel("oracledesk-wallet-sync"); + channel.postMessage({ + type: "wallet_disconnected", + timestamp: Date.now(), + } as SyncMessage); + channel.close(); + } catch { + // BroadcastChannel not available + } +} diff --git a/lib/api/jwt-manager.ts b/lib/api/jwt-manager.ts new file mode 100644 index 0000000..da01042 --- /dev/null +++ b/lib/api/jwt-manager.ts @@ -0,0 +1,95 @@ +/** + * JWT lifecycle management: expiry checking, invalid token handling, + * single-flight re-authentication to prevent thundering herd. + */ + +interface JWTPayload { + exp: number; + iat: number; + [key: string]: unknown; +} + +/** + * Decode JWT payload without verification (client-side only). + * Does not validate signature; use on decoded tokens only. + */ +export function decodeJWT(token: string): JWTPayload | null { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + + const payload = parts[1]; + const decoded = atob(payload.replace(/-/g, "+").replace(/_/g, "/")); + return JSON.parse(decoded) as JWTPayload; + } catch { + return null; + } +} + +/** + * Check if a token is expired (exp claim in the past). + * Returns true if expired or invalid. + */ +export function isTokenExpired(token: string): boolean { + const payload = decodeJWT(token); + if (!payload || typeof payload.exp !== "number") { + return true; + } + + const expiresAt = payload.exp * 1000; // JWT exp is in seconds + const now = Date.now(); + // Consider token expired if within 30s of expiry (skew + time for request) + const bufferMs = 30 * 1000; + + return now > expiresAt - bufferMs; +} + +/** + * Get time remaining until token expires (in milliseconds). + * Returns 0 if already expired. + */ +export function getTokenTTL(token: string): number { + const payload = decodeJWT(token); + if (!payload || typeof payload.exp !== "number") { + return 0; + } + + const expiresAt = payload.exp * 1000; + const now = Date.now(); + return Math.max(0, expiresAt - now); +} + +/** + * Single-flight re-auth: track in-flight auth requests to prevent + * multiple concurrent re-auth attempts. + */ +let reAuthPromise: Promise | null = null; + +export function setReAuthPromise(promise: Promise | null) { + reAuthPromise = promise; +} + +export function getReAuthPromise() { + return reAuthPromise; +} + +/** + * Check if re-auth is currently in flight. + */ +export function isReAuthInFlight(): boolean { + return reAuthPromise !== null; +} + +/** + * Wait for in-flight re-auth to complete (if any). + * No-op if no re-auth in progress. + */ +export async function waitForReAuth(): Promise { + if (reAuthPromise) { + try { + await reAuthPromise; + } catch { + // Re-auth failed; let the caller handle it + } + } +} diff --git a/lib/stellar/WalletProvider.tsx b/lib/stellar/WalletProvider.tsx index ae89ea6..bf99a4e 100644 --- a/lib/stellar/WalletProvider.tsx +++ b/lib/stellar/WalletProvider.tsx @@ -4,6 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState, t import { useRouter } from "next/navigation"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { clearAuthSession, getStoredSessionWallet, signInWithWallet } from "@/lib/api/auth"; +import { createCrossTabSync, broadcastWalletDisconnect, type SyncMessage } from "@/lib/api/cross-tab-sync"; import { NETWORK_PASSPHRASE } from "./config"; import { describeError } from "./errors"; import type { Signer } from "./clients"; @@ -67,6 +68,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { StellarWalletsKit.init({ modules: defaultModules(), network: Networks.TESTNET }); setKit(() => StellarWalletsKit); + // Handle wallet kit state updates unsubscribe.push( StellarWalletsKit.on(KitEventType.STATE_UPDATED, ({ payload }) => { const next = payload.address ?? null; @@ -84,6 +86,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { setWalletNetworkPassphrase(null); clearAuthSession(); setSessionWallet(null); + broadcastWalletDisconnect(); }), ); @@ -97,6 +100,37 @@ export function WalletProvider({ children }: { children: ReactNode }) { } if (!cancelled) setSessionWallet(getStoredSessionWallet()); })(); + + // Setup cross-tab sync listener + const crossTabSync = createCrossTabSync((msg: SyncMessage) => { + if (cancelled) return; + + switch (msg.type) { + case "wallet_disconnected": + setAddress(null); + setWalletNetworkPassphrase(null); + clearAuthSession(); + setSessionWallet(null); + break; + + case "wallet_changed": + if (msg.address !== getStoredSessionWallet()) { + setAddress(msg.address || null); + // Session will be re-validated on next use + } + break; + + case "auth_session_changed": + // Another tab changed the session; update our stored wallet reference + const newWallet = msg.address || getStoredSessionWallet(); + if (newWallet && newWallet !== getStoredSessionWallet()) { + setSessionWallet(newWallet); + } + break; + } + }); + unsubscribe.push(() => crossTabSync.unsubscribe()); + return () => { cancelled = true; unsubscribe.forEach((fn) => fn()); diff --git a/lib/stellar/sep10-validator.ts b/lib/stellar/sep10-validator.ts new file mode 100644 index 0000000..ee72465 --- /dev/null +++ b/lib/stellar/sep10-validator.ts @@ -0,0 +1,129 @@ +import { TransactionBuilder, Networks, FeeBumpTransaction } from "@stellar/stellar-sdk"; +import type { AuthChallenge } from "@/lib/api/auth"; +import { NETWORK_PASSPHRASE } from "./config"; + +/** + * Validate SEP-10 challenge transaction structure before signing. + * Ensures the challenge is well-formed and matches expected parameters. + */ + +export interface SEP10ValidationError { + kind: "invalid_xdr" | "invalid_structure" | "wrong_network" | "expired" | "invalid_signing_key"; + message: string; +} + +/** + * Parse transaction XDR safely. + */ +function parseTransactionXDR(xdr: string): { transaction: any; isFeeBump: boolean } | null { + try { + const tx = TransactionBuilder.fromXDR(xdr, NETWORK_PASSPHRASE); + const isFeeBump = tx instanceof FeeBumpTransaction; + return { transaction: tx, isFeeBump }; + } catch { + return null; + } +} + +/** + * Get the inner transaction from a fee-bump or regular transaction. + */ +function getInnerTransaction(tx: any): any { + return tx instanceof FeeBumpTransaction ? tx.innerTransaction : tx; +} + +/** + * Validate a SEP-10 challenge transaction. + * + * Checks: + * 1. XDR is valid and parseable + * 2. Transaction is signed by the backend (at least one signature) + * 3. Network passphrase matches expected + * 4. Expiry time is valid (not expired) + * 5. Challenge transaction has the expected structure (single operation) + */ +export function validateSEP10Challenge( + challenge: AuthChallenge, + expectedNetworkPassphrase: string = NETWORK_PASSPHRASE, +): SEP10ValidationError | null { + // Validate XDR format + const parsed = parseTransactionXDR(challenge.transaction); + if (!parsed) { + return { + kind: "invalid_xdr", + message: "Invalid SEP-10 challenge: transaction XDR is malformed", + }; + } + + const { transaction: tx } = parsed; + const innerTx = getInnerTransaction(tx); + + // Validate network passphrase + if (innerTx.networkPassphrase !== expectedNetworkPassphrase) { + return { + kind: "wrong_network", + message: `Invalid SEP-10 challenge: expected network ${expectedNetworkPassphrase}, got ${innerTx.networkPassphrase}`, + }; + } + + // Validate expiry time + const expiresAt = new Date(challenge.expiresAt).getTime(); + const now = Date.now(); + if (now > expiresAt) { + return { + kind: "expired", + message: "Invalid SEP-10 challenge: challenge has expired", + }; + } + + // Validate transaction has at least one signature (from backend) + const signatures = tx instanceof FeeBumpTransaction ? tx.signatures : innerTx.signatures; + if (!signatures || signatures.length === 0) { + return { + kind: "invalid_signing_key", + message: "Invalid SEP-10 challenge: transaction is not signed by the backend", + }; + } + + // SEP-10 challenge should have exactly one operation (ManageData for the challenge) + if (innerTx.operations.length !== 1) { + return { + kind: "invalid_structure", + message: `Invalid SEP-10 challenge: expected 1 operation, got ${innerTx.operations.length}`, + }; + } + + const operation = innerTx.operations[0]; + if (operation.type !== "manageData") { + return { + kind: "invalid_structure", + message: `Invalid SEP-10 challenge: expected ManageData operation, got ${operation.type}`, + }; + } + + return null; +} + +/** + * Extract the challenge token from a ManageData operation. + * The token is stored in the operation's value field. + */ +export function extractChallengeToken(xdr: string): string | null { + const parsed = parseTransactionXDR(xdr); + if (!parsed) return null; + + const { transaction: tx } = parsed; + const innerTx = getInnerTransaction(tx); + + if (innerTx.operations.length !== 1) return null; + + const operation = innerTx.operations[0]; + if (operation.type !== "manageData") return null; + + // ManageData value is stored as a Buffer, convert to base64 + if (operation.value) { + return Buffer.from(operation.value).toString("base64"); + } + + return null; +}