From 6247f8b3b2ddffcead134252243014cb86641c0f Mon Sep 17 00:00:00 2001 From: eitighis Date: Tue, 29 Sep 2026 16:04:37 +0100 Subject: [PATCH] fix: replace mock JWT tokens with real wallet-signature auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the SEP-10-style challenge-transaction login into the app. After wallet connect, register now runs the real flow via authService.authenticate: fetch a nonce + challenge XDR, sign the challenge with the connected wallet (Freighter directly, Lobstr over WalletConnect using the stored sessionId), and exchange the signed challenge for JWT tokens — replacing the mock-access-token / mock-refresh-token placeholders. - services/auth.service.ts: add WalletType, sep0010 verify body, and the authenticate() orchestration; wallet signing failures surface as a WALLET_SIGNING_FAILED ApiClientError. - types/errors.ts: add WALLET_SIGNING_FAILED code + user message. - app/(auth)/register.tsx: call authenticate() with a wallet guard, keeping the existing error banner and finally reset. - Delete the dead legacy simulated sign-in path (components/pages/ SignIn.tsx, hooks/auth/use-sign-in.ts) — nothing referenced them. - Add services/__tests__/auth.service.test.ts covering both wallet types and the signing/network error paths. Closes #35 --- app/(auth)/register.tsx | 16 ++- components/pages/SignIn.tsx | 146 ------------------------ hooks/auth/use-sign-in.ts | 130 --------------------- services/__tests__/auth.service.test.ts | 137 ++++++++++++++++++++++ services/auth.service.ts | 67 ++++++++++- types/errors.ts | 4 + 6 files changed, 218 insertions(+), 282 deletions(-) delete mode 100644 components/pages/SignIn.tsx delete mode 100644 hooks/auth/use-sign-in.ts create mode 100644 services/__tests__/auth.service.test.ts diff --git a/app/(auth)/register.tsx b/app/(auth)/register.tsx index a8adf70..b7d03ac 100644 --- a/app/(auth)/register.tsx +++ b/app/(auth)/register.tsx @@ -16,6 +16,7 @@ import { Input } from '../../components/shared/Input'; import { useUserStore } from '../../stores/user.store'; import { useAuthStore } from '../../stores/auth.store'; import { useWalletStore } from '../../stores/wallet.store'; +import { authService } from '../../services/auth.service'; import { useTranslation } from '../../hooks/useTranslation'; import type { LearnerProfile } from '../../types/user.types'; @@ -27,6 +28,8 @@ export default function RegisterScreen() { const setTokens = useAuthStore((s) => s.setTokens); const setWallet = useAuthStore((s) => s.setWallet); const publicKey = useWalletStore((s) => s.address); + const walletType = useWalletStore((s) => s.walletType); + const sessionId = useWalletStore((s) => s.sessionId); const [displayName, setDisplayName] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); @@ -49,12 +52,16 @@ export default function RegisterScreen() { const handleComplete = async () => { if (!isValid) return; + if (!publicKey || !walletType) { + setSubmitError('No wallet connected. Please connect your wallet and try again.'); + return; + } setIsSubmitting(true); setSubmitError(null); try { const profile: LearnerProfile = { - walletAddress: publicKey ?? '', + walletAddress: publicKey, displayName: displayName.trim(), role: role ?? 'learner', school: isLearner ? school.trim() : undefined, @@ -66,8 +73,11 @@ export default function RegisterScreen() { setProfile(profile); - await setTokens('mock-access-token', 'mock-refresh-token'); - await setWallet(publicKey ?? ''); + // Real wallet-signature auth: sign the SEP-10-style challenge with the + // connected wallet and exchange it for JWT tokens. + const tokens = await authService.authenticate(publicKey, walletType, sessionId); + await setTokens(tokens.accessToken, tokens.refreshToken); + await setWallet(publicKey); } catch (err) { console.error('[register] failed to complete registration', err); const message = diff --git a/components/pages/SignIn.tsx b/components/pages/SignIn.tsx deleted file mode 100644 index c95f565..0000000 --- a/components/pages/SignIn.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import React from 'react'; -import { - View, - Text, - TextInput, - TouchableOpacity, - Pressable, - Image, - KeyboardAvoidingView, - Platform, - ScrollView, - Alert, -} from 'react-native'; -import { User, Lock, Eye, EyeOff, Wallet, ArrowRight } from 'lucide-react-native'; -import { useSignIn } from '../../hooks/auth/use-sign-in'; - -export default function SignInScreen() { - const { - formState, - isValid, - handleUsernameChange, - handlePasswordChange, - toggleSecureText, - handleSignIn, - } = useSignIn(); - - return ( - - {/* contentContainerStyle does not support className in NativeWind */} - - {/* Logo Section */} - - - {/* Image requires numeric dimensions; resizeMode set via prop */} - - - Trust Up - - Build your reputation, unlock your credit - - - - {/* Form Fields */} - - Username - - - - - - Password - - - - - {formState.secureText ? ( - - ) : ( - - )} - - - - - Forgot password? - - - {/* Sign In Button */} - - - Sign In - - - - - - {/* Divider */} - - - OR - - - - {/* Connect Wallet */} - - - Connect Wallet - - - {/* Footer */} - - Don't have an account? - - Sign Up - - - - - ); -} diff --git a/hooks/auth/use-sign-in.ts b/hooks/auth/use-sign-in.ts deleted file mode 100644 index 17434ee..0000000 --- a/hooks/auth/use-sign-in.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { useState, useCallback, useMemo } from 'react'; - -/** - * Form field state for the sign-in form - */ -export interface SignInFormState { - username: string; - password: string; - secureText: boolean; -} - -/** - * Validation errors for sign-in form - */ -export interface SignInErrors { - username: string; - password: string; -} - -/** - * Return type for useSignIn hook - */ -export interface UseSignInReturn { - // Form state - formState: SignInFormState; - errors: SignInErrors; - isSubmitting: boolean; - isValid: boolean; - - // Field change handlers - handleUsernameChange: (text: string) => void; - handlePasswordChange: (text: string) => void; - toggleSecureText: () => void; - - // Form submission - handleSignIn: () => void; -} - -/** - * Initial form state - */ -const initialFormState: SignInFormState = { - username: '', - password: '', - secureText: true, -}; - -/** - * Initial errors state - */ -const initialErrors: SignInErrors = { - username: '', - password: '', -}; - -/** - * Custom hook for managing sign-in form state and validation - */ -export const useSignIn = (): UseSignInReturn => { - const [formState, setFormState] = useState(initialFormState); - const [errors, setErrors] = useState(initialErrors); - const [isSubmitting, setIsSubmitting] = useState(false); - - // Field change handlers - const handleUsernameChange = useCallback((text: string) => { - setFormState((prev: SignInFormState) => ({ ...prev, username: text })); - - if (text.trim().length === 0) { - setErrors((prev: SignInErrors) => ({ ...prev, username: 'Username is required' })); - } else { - setErrors((prev: SignInErrors) => ({ ...prev, username: '' })); - } - }, []); - - const handlePasswordChange = useCallback((text: string) => { - setFormState((prev: SignInFormState) => ({ ...prev, password: text })); - - if (text.trim().length === 0) { - setErrors((prev: SignInErrors) => ({ ...prev, password: 'Password is required' })); - } else { - setErrors((prev: SignInErrors) => ({ ...prev, password: '' })); - } - }, []); - - const toggleSecureText = useCallback(() => { - setFormState((prev: SignInFormState) => ({ ...prev, secureText: !prev.secureText })); - }, []); - - // Form validation - const isValid = useMemo(() => { - return ( - formState.username.trim().length > 0 && - formState.password.trim().length > 0 && - errors.username === '' && - errors.password === '' - ); - }, [formState, errors]); - - // Sign-in handler - const handleSignIn = useCallback(() => { - if (!isValid) return; - - setIsSubmitting(true); - - // Simulate API call - setTimeout(() => { - console.log('Sign In Data:', { - username: formState.username, - password: formState.password, - }); - setIsSubmitting(false); - }, 1000); - }, [isValid, formState]); - - return { - // Form state - formState, - errors, - isSubmitting, - isValid, - - // Field change handlers - handleUsernameChange, - handlePasswordChange, - toggleSecureText, - - // Form submission - handleSignIn, - }; -}; diff --git a/services/__tests__/auth.service.test.ts b/services/__tests__/auth.service.test.ts new file mode 100644 index 0000000..8e76ec4 --- /dev/null +++ b/services/__tests__/auth.service.test.ts @@ -0,0 +1,137 @@ +/** + * Tests for the wallet-signature auth service (services/auth.service.ts). + * + * Focus: the end-to-end `authenticate` orchestration for the SEP-10-style + * challenge-transaction flow — + * getNonce → sign challengeXdr with the connected wallet → verify → tokens. + * + * Both wallet types are covered (Freighter signs directly; Lobstr signs over + * WalletConnect using the stored sessionId), along with the error paths: + * a wallet signing failure and a missing Lobstr session both surface as a + * `WALLET_SIGNING_FAILED` ApiClientError, and network failures propagate as + * ApiClientError from the underlying calls. + */ + +/* eslint-disable @typescript-eslint/no-require-imports */ + +const mockPost = jest.fn(); +const mockSignWithFreighter = jest.fn(); +const mockSignWithLobstr = jest.fn(); + +jest.mock('../api', () => ({ + __esModule: true, + default: { post: mockPost }, +})); + +jest.mock('../wallet.service', () => ({ + walletService: { + signWithFreighter: mockSignWithFreighter, + signWithLobstr: mockSignWithLobstr, + }, +})); + +jest.mock('../sentry', () => ({ + addBreadcrumb: jest.fn(), + captureServiceError: jest.fn(), +})); + +import { authService } from '../auth.service'; +import { ApiClientError, ApiErrorCode } from '../../types/errors'; + +const WALLET = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW'; +const NONCE = 'a1b2c3d4e5f67890abcdef1234567890a1b2c3d4e5f67890abcdef1234567890'; +const CHALLENGE_XDR = 'AAAAAgAAAAMOCK_CHALLENGE_XDR'; +const SIGNED_XDR = 'AAAAAgAAAAMOCK_SIGNED_XDR'; + +const NONCE_RESPONSE = { + nonce: NONCE, + expiresAt: '2026-01-01T00:05:00.000Z', + challengeXdr: CHALLENGE_XDR, +}; +const TOKENS = { + accessToken: 'real.access.jwt', + refreshToken: 'real.refresh.jwt', + expiresIn: 3600, +}; + +/** Wires the two POSTs authenticate makes: /auth/nonce then /auth/verify. */ +function wireHappyPath() { + mockPost.mockImplementation((url: string) => { + if (url === '/auth/nonce') return Promise.resolve({ data: NONCE_RESPONSE }); + if (url === '/auth/verify') return Promise.resolve({ data: TOKENS }); + return Promise.reject(new Error(`unexpected POST ${url}`)); + }); +} + +describe('authService.authenticate (SEP-10 challenge transaction)', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('freighter: fetches a nonce, signs the challengeXdr, verifies as sep0010, returns tokens', async () => { + wireHappyPath(); + mockSignWithFreighter.mockResolvedValue(SIGNED_XDR); + + const tokens = await authService.authenticate(WALLET, 'freighter', null); + + expect(mockPost).toHaveBeenNthCalledWith(1, '/auth/nonce', { wallet: WALLET }); + expect(mockSignWithFreighter).toHaveBeenCalledWith(CHALLENGE_XDR); + expect(mockSignWithLobstr).not.toHaveBeenCalled(); + expect(mockPost).toHaveBeenNthCalledWith(2, '/auth/verify', { + wallet: WALLET, + nonce: NONCE, + signedXdr: SIGNED_XDR, + signatureType: 'sep0010', + }); + expect(tokens).toEqual(TOKENS); + }); + + it('lobstr: signs the challengeXdr over WalletConnect using the sessionId', async () => { + wireHappyPath(); + mockSignWithLobstr.mockResolvedValue(SIGNED_XDR); + + const tokens = await authService.authenticate(WALLET, 'lobstr', 'session-123'); + + expect(mockSignWithLobstr).toHaveBeenCalledWith(CHALLENGE_XDR, 'session-123', WALLET); + expect(mockSignWithFreighter).not.toHaveBeenCalled(); + expect(mockPost).toHaveBeenNthCalledWith(2, '/auth/verify', { + wallet: WALLET, + nonce: NONCE, + signedXdr: SIGNED_XDR, + signatureType: 'sep0010', + }); + expect(tokens).toEqual(TOKENS); + }); + + it('lobstr with no sessionId → WALLET_SIGNING_FAILED (verify never called)', async () => { + wireHappyPath(); + + await expect(authService.authenticate(WALLET, 'lobstr', null)).rejects.toMatchObject({ + code: ApiErrorCode.WALLET_SIGNING_FAILED, + }); + expect(mockSignWithLobstr).not.toHaveBeenCalled(); + expect(mockPost).toHaveBeenCalledTimes(1); // only /auth/nonce + expect(mockPost).not.toHaveBeenCalledWith('/auth/verify', expect.anything()); + }); + + it('wraps a wallet signing rejection as a WALLET_SIGNING_FAILED ApiClientError', async () => { + wireHappyPath(); + mockSignWithFreighter.mockRejectedValue(new Error('user rejected')); + + const err = await authService.authenticate(WALLET, 'freighter', null).catch((e) => e); + expect(err).toBeInstanceOf(ApiClientError); + expect(err.code).toBe(ApiErrorCode.WALLET_SIGNING_FAILED); + expect(mockPost).not.toHaveBeenCalledWith('/auth/verify', expect.anything()); + }); + + it('propagates a nonce network failure as ApiClientError (signing never attempted)', async () => { + mockPost.mockRejectedValueOnce( + new ApiClientError({ code: ApiErrorCode.NETWORK_ERROR, message: 'down' }) + ); + + const err = await authService.authenticate(WALLET, 'freighter', null).catch((e) => e); + expect(err).toBeInstanceOf(ApiClientError); + expect(err.code).toBe(ApiErrorCode.NETWORK_ERROR); + expect(mockSignWithFreighter).not.toHaveBeenCalled(); + }); +}); diff --git a/services/auth.service.ts b/services/auth.service.ts index 22caaff..88eb0bd 100644 --- a/services/auth.service.ts +++ b/services/auth.service.ts @@ -1,10 +1,24 @@ import api from './api'; +import { walletService } from './wallet.service'; import { addBreadcrumb, captureServiceError } from './sentry'; import { ApiClientError, ApiErrorCode } from '../types/errors'; +export type WalletType = 'freighter' | 'lobstr'; + export interface NonceResponse { nonce: string; expiresAt: string; + /** + * Canonical challenge message (used by the message-signing schemes). Retained + * for completeness; the SEP-10 flow signs `challengeXdr` instead. + */ + message?: string; + /** + * Unsigned SEP-10-style challenge transaction (base64 envelope XDR). The + * connected wallet signs this and returns it as `signedXdr` — this is what + * lets transaction-only wallets (mobile Lobstr over WalletConnect) authenticate. + */ + challengeXdr: string; } export interface AuthTokens { @@ -33,10 +47,15 @@ export const authService = { } }, - async verify(wallet: string, nonce: string, signature: string): Promise { - addBreadcrumb('auth.service', 'Verifying wallet signature'); + async verify(wallet: string, nonce: string, signedXdr: string): Promise { + addBreadcrumb('auth.service', 'Verifying signed challenge transaction'); try { - const res = await api.post('/auth/verify', { wallet, nonce, signature }); + const res = await api.post('/auth/verify', { + wallet, + nonce, + signedXdr, + signatureType: 'sep0010', + }); addBreadcrumb('auth.service', 'Wallet verified successfully'); return res.data; } catch (error) { @@ -51,6 +70,48 @@ export const authService = { } }, + /** + * End-to-end wallet-signature login: fetch a nonce + challenge transaction, + * have the connected wallet sign the challenge XDR (Lobstr over WalletConnect + * or Freighter — both sign transactions, which is exactly what the SEP-10-style + * scheme needs), then exchange the signed challenge for JWT tokens. + * + * Errors surface as `ApiClientError` from the underlying calls; a wallet + * rejection/failure surfaces as a `WALLET_SIGNING_FAILED` `ApiClientError`. + */ + async authenticate( + wallet: string, + walletType: WalletType, + sessionId: string | null + ): Promise { + addBreadcrumb('auth.service', `Authenticating via ${walletType}`); + const { nonce, challengeXdr } = await this.getNonce(wallet); + + let signedXdr: string; + try { + if (walletType === 'lobstr') { + if (!sessionId) { + throw new Error('Lobstr session not found. Please reconnect your wallet.'); + } + signedXdr = await walletService.signWithLobstr(challengeXdr, sessionId, wallet); + } else { + signedXdr = await walletService.signWithFreighter(challengeXdr); + } + } catch (error) { + captureServiceError('auth', 'authenticate.sign', error); + if (error instanceof ApiClientError) throw error; + throw new ApiClientError({ + code: ApiErrorCode.WALLET_SIGNING_FAILED, + message: 'Failed to sign the authentication challenge', + userMessage: + 'Could not sign the authentication request with your wallet. Please try again.', + cause: error, + }); + } + + return this.verify(wallet, nonce, signedXdr); + }, + async refresh(refreshToken: string): Promise { addBreadcrumb('auth.service', 'Refreshing auth tokens'); try { diff --git a/types/errors.ts b/types/errors.ts index 1afdb00..5626cbb 100644 --- a/types/errors.ts +++ b/types/errors.ts @@ -30,6 +30,8 @@ export enum ApiErrorCode { NETWORK_ERROR = 'NETWORK_ERROR', /** Request was queued for offline replay */ OFFLINE_QUEUED = 'OFFLINE_QUEUED', + /** Wallet failed to sign, or the user rejected the signing request */ + WALLET_SIGNING_FAILED = 'WALLET_SIGNING_FAILED', /** Catch-all for unexpected errors */ UNKNOWN = 'UNKNOWN', } @@ -168,6 +170,8 @@ function userFacingMessage(code: ApiErrorCode, statusCode: number, serverMsg?: s return 'A network error occurred. Please check your connection.'; case ApiErrorCode.OFFLINE_QUEUED: return "We'll complete this action once you're back online."; + case ApiErrorCode.WALLET_SIGNING_FAILED: + return 'Could not sign the request with your wallet. Please try again.'; default: return 'An unexpected error occurred. Please try again.'; }