diff --git a/apps/backend/lambdas/auth/handler.ts b/apps/backend/lambdas/auth/handler.ts index a2d346e0..feb24c30 100644 --- a/apps/backend/lambdas/auth/handler.ts +++ b/apps/backend/lambdas/auth/handler.ts @@ -6,10 +6,8 @@ import { AdminDeleteUserCommand, InitiateAuthCommand, InitiateAuthCommandInput, - ConfirmSignUpCommandInput, ConfirmSignUpCommand, ResendConfirmationCodeCommand, - ResendConfirmationCodeCommandInput, GlobalSignOutCommand, GlobalSignOutCommandInput, } from '@aws-sdk/client-cognito-identity-provider'; @@ -33,6 +31,11 @@ export const handler = async (event: any): Promise => { const normalizedPath = rawPath.replace(/\/$/, ''); const method = (event.requestContext?.http?.method || event.httpMethod || 'GET').toUpperCase(); + // CORS preflight + if (method === 'OPTIONS') { + return json(200, {}); + } + // Health check if ((normalizedPath.endsWith('/health') || normalizedPath === '/health') && method === 'GET') { return json(200, { ok: true, timestamp: new Date().toISOString() }); @@ -59,18 +62,34 @@ export const handler = async (event: any): Promise => { if (!email || !code) { return json(400, { message: 'email and code are required' }); } - const params: ConfirmSignUpCommandInput = { - ClientId: USER_POOL_CLIENT_ID, - Username: email as string, - ConfirmationCode: code as string, - }; - const response = await cognitoClient.send(new ConfirmSignUpCommand(params)); - if (!response.Session) { - return json(400, { message: 'Invalid code or email' }); + try { + await cognitoClient.send(new ConfirmSignUpCommand({ + ClientId: USER_POOL_CLIENT_ID, + Username: email as string, + ConfirmationCode: code as string, + })); + return json(200, { message: `Email verified successfully for ${email}` }); + } catch (error: any) { + if (error.name === 'ExpiredCodeException') { + return json(400, { message: 'Verification code has expired, please request a new one' }); + } + if (error.name === 'CodeMismatchException') { + return json(400, { message: 'Invalid verification code' }); + } + if (error.name === 'NotAuthorizedException') { + return json(400, { message: 'User is already confirmed' }); + } + if (error.name === 'UserNotFoundException') { + return json(404, { message: 'User not found' }); + } + if (error.name === 'LimitExceededException') { + return json(429, { message: 'Too many attempts, please try again later' }); + } + console.error('Verify email error:', error); + return json(500, { message: 'Failed to verify email' }); } - return json(200, { message: `Email verified successfully for ${email}, session: ${response.Session}` }); } - + // POST /resend-code if (normalizedPath === '/resend-code' && method === 'POST') { const body = event.body ? JSON.parse(event.body) as Record : {}; @@ -78,15 +97,25 @@ export const handler = async (event: any): Promise => { if (!email) { return json(400, { message: 'email is required' }); } - const params: ResendConfirmationCodeCommandInput = { - ClientId: USER_POOL_CLIENT_ID, - Username: email as string, - }; - const response = await cognitoClient.send(new ResendConfirmationCodeCommand(params)); - if (!response.CodeDeliveryDetails) { - return json(400, { message: 'Failed to resend code' }); + try { + await cognitoClient.send(new ResendConfirmationCodeCommand({ + ClientId: USER_POOL_CLIENT_ID, + Username: email as string, + })); + return json(200, { message: `Verification code resent to ${email}` }); + } catch (error: any) { + if (error.name === 'UserNotFoundException') { + return json(404, { message: 'User not found' }); + } + if (error.name === 'InvalidParameterException') { + return json(400, { message: 'User is already confirmed' }); + } + if (error.name === 'LimitExceededException') { + return json(429, { message: 'Too many attempts, please try again later' }); + } + console.error('Resend code error:', error); + return json(500, { message: 'Failed to resend verification code' }); } - return json(200, { message: `Code resent successfully for ${email}, delivery details: ${response.CodeDeliveryDetails}` }); } // POST /logout diff --git a/apps/backend/lambdas/auth/test/auth.unit.test.ts b/apps/backend/lambdas/auth/test/auth.unit.test.ts index b2bada59..72f60b86 100644 --- a/apps/backend/lambdas/auth/test/auth.unit.test.ts +++ b/apps/backend/lambdas/auth/test/auth.unit.test.ts @@ -133,3 +133,45 @@ test("invalid path returns 404", async () => { expect(res.statusCode).toBe(404); }); + +test("OPTIONS preflight returns 200 with CORS headers", async () => { + const res = await handler(createEvent('/login', 'OPTIONS')); + expect(res.statusCode).toBe(200); + expect(res.headers?.['Access-Control-Allow-Origin']).toBe('*'); +}); + +test("verify-email missing email returns 400", async () => { + const res = await handler(createEvent('/verify-email', 'POST', { code: '123456' })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toContain('required'); +}); + +test("verify-email missing code returns 400", async () => { + const res = await handler(createEvent('/verify-email', 'POST', { email: 'test@example.com' })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toContain('required'); +}); + +test("resend-code missing email returns 400", async () => { + const res = await handler(createEvent('/resend-code', 'POST', {})); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toContain('required'); +}); + +test("logout missing authorization header returns 401", async () => { + const res = await handler(createEvent('/logout', 'POST')); + expect(res.statusCode).toBe(401); + expect(JSON.parse(res.body).message).toContain('Authorization'); +}); + +test("login missing email returns 400", async () => { + const res = await handler(createEvent('/login', 'POST', { password: 'TestPassword123' })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toContain('required'); +}); + +test("login missing password returns 400", async () => { + const res = await handler(createEvent('/login', 'POST', { email: 'test@example.com' })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toContain('required'); +}); diff --git a/apps/frontend/src/app/providers.tsx b/apps/frontend/src/app/providers.tsx index 62c63007..7605b205 100644 --- a/apps/frontend/src/app/providers.tsx +++ b/apps/frontend/src/app/providers.tsx @@ -1,7 +1,12 @@ 'use client'; import { ChakraProvider, defaultSystem } from '@chakra-ui/react'; +import { AuthProvider } from '@/context/AuthContext'; export function Providers({ children }: { children: React.ReactNode }) { - return {children}; + return ( + + {children} + + ); } diff --git a/apps/frontend/src/context/AuthContext.tsx b/apps/frontend/src/context/AuthContext.tsx new file mode 100644 index 00000000..eb57dbca --- /dev/null +++ b/apps/frontend/src/context/AuthContext.tsx @@ -0,0 +1,177 @@ +'use client'; + +import { createContext, useContext, useEffect, useState } from 'react'; +import { apiFetch } from '@/lib/api'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface User { + sub: string; + email: string; + name?: string; +} + +interface AuthTokens { + accessToken: string; + idToken: string; + refreshToken: string; +} + +interface AuthContextValue { + user: User | null; + isAuthenticated: boolean; + isLoading: boolean; + login: (email: string, password: string) => Promise; + register: (email: string, password: string, name: string) => Promise; + verifyEmail: (email: string, code: string) => Promise; + resendCode: (email: string) => Promise; + logout: () => Promise; + getAccessToken: () => string | null; +} + +// --------------------------------------------------------------------------- +// Backend response shapes +// --------------------------------------------------------------------------- + +interface LoginResponse { + AccessToken: string; + IdToken: string; + RefreshToken: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const STORAGE_KEYS = { + ACCESS: 'branch_access_token', + ID: 'branch_id_token', + REFRESH: 'branch_refresh_token', +} as const; + +function decodeIdToken(token: string): User | null { + try { + const payload = token.split('.')[1]; + const padded = payload.replace(/-/g, '+').replace(/_/g, '/'); + const json = atob(padded.padEnd(padded.length + ((4 - (padded.length % 4)) % 4), '=')); + const claims = JSON.parse(json); + return { + sub: claims.sub, + email: claims.email, + name: claims.name ?? claims['cognito:username'], + }; + } catch { + return null; + } +} + +function saveTokens({ accessToken, idToken, refreshToken }: AuthTokens) { + localStorage.setItem(STORAGE_KEYS.ACCESS, accessToken); + localStorage.setItem(STORAGE_KEYS.ID, idToken); + localStorage.setItem(STORAGE_KEYS.REFRESH, refreshToken); +} + +function clearTokens() { + localStorage.removeItem(STORAGE_KEYS.ACCESS); + localStorage.removeItem(STORAGE_KEYS.ID); + localStorage.removeItem(STORAGE_KEYS.REFRESH); +} + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + // Restore session from localStorage on mount + useEffect(() => { + const idToken = localStorage.getItem(STORAGE_KEYS.ID); + if (idToken) { + setUser(decodeIdToken(idToken)); + } + setIsLoading(false); + }, []); + + async function login(email: string, password: string) { + const data = await apiFetch('/auth/login', { + method: 'POST', + body: JSON.stringify({ email, password }), + }); + const tokens: AuthTokens = { + accessToken: data.AccessToken, + idToken: data.IdToken, + refreshToken: data.RefreshToken, + }; + saveTokens(tokens); + setUser(decodeIdToken(tokens.idToken)); + } + + async function register(email: string, password: string, name: string) { + await apiFetch('/auth/register', { + method: 'POST', + body: JSON.stringify({ email, password, name }), + }); + } + + async function verifyEmail(email: string, code: string) { + await apiFetch('/auth/verify-email', { + method: 'POST', + body: JSON.stringify({ email, code }), + }); + } + + async function resendCode(email: string) { + await apiFetch('/auth/resend-code', { + method: 'POST', + body: JSON.stringify({ email }), + }); + } + + async function logout() { + const accessToken = localStorage.getItem(STORAGE_KEYS.ACCESS); + if (accessToken) { + await apiFetch('/auth/logout', { + method: 'POST', + token: accessToken, + }).catch(() => { + // Best-effort — clear locally even if the server call fails + }); + } + clearTokens(); + setUser(null); + } + + function getAccessToken() { + return localStorage.getItem(STORAGE_KEYS.ACCESS); + } + + return ( + + {children} + + ); +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used inside AuthProvider'); + return ctx; +} diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts new file mode 100644 index 00000000..8bd4628d --- /dev/null +++ b/apps/frontend/src/lib/api.ts @@ -0,0 +1,26 @@ +const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3006'; + +interface RequestOptions extends RequestInit { + token?: string; +} + +export async function apiFetch( + path: string, + { token, headers, ...options }: RequestOptions = {}, +): Promise { + const res = await fetch(`${BASE_URL}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...headers, + }, + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.message ?? res.statusText); + } + + return res.json() as Promise; +} diff --git a/apps/frontend/test/context/AuthContext.test.tsx b/apps/frontend/test/context/AuthContext.test.tsx new file mode 100644 index 00000000..7b7ac2fa --- /dev/null +++ b/apps/frontend/test/context/AuthContext.test.tsx @@ -0,0 +1,138 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { AuthProvider, useAuth } from '@/context/AuthContext'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build a minimal JWT whose payload contains the given claims. */ +function makeIdToken(claims: Record) { + const payload = btoa(JSON.stringify(claims)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + return `eyJhbGciOiJSUzI1NiJ9.${payload}.signature`; +} + +const TEST_TOKENS = { + AccessToken: 'test-access-token', + IdToken: makeIdToken({ sub: 'sub-123', email: 'jane@example.com', name: 'Jane' }), + RefreshToken: 'test-refresh-token', +}; + +function mockFetch(body: unknown, ok = true) { + global.fetch = jest.fn().mockResolvedValue({ + ok, + statusText: 'Unauthorized', + json: jest.fn().mockResolvedValue(body), + } as unknown as Response); +} + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +beforeEach(() => localStorage.clear()); +afterEach(() => jest.restoreAllMocks()); + +describe('AuthProvider / useAuth', () => { + it('throws when used outside AuthProvider', () => { + // suppress expected console.error from React + jest.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => renderHook(() => useAuth())).toThrow('useAuth must be used inside AuthProvider'); + }); + + it('starts with no user and finishes loading', async () => { + const { result } = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.user).toBeNull(); + expect(result.current.isAuthenticated).toBe(false); + }); + + it('restores user from localStorage on mount', async () => { + localStorage.setItem('branch_id_token', TEST_TOKENS.IdToken); + const { result } = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.user).toMatchObject({ sub: 'sub-123', email: 'jane@example.com', name: 'Jane' }); + expect(result.current.isAuthenticated).toBe(true); + }); + + it('login stores tokens and sets user state', async () => { + mockFetch(TEST_TOKENS); + const { result } = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.login('jane@example.com', 'password123'); + }); + + expect(result.current.isAuthenticated).toBe(true); + expect(result.current.user).toMatchObject({ email: 'jane@example.com' }); + expect(localStorage.getItem('branch_access_token')).toBe('test-access-token'); + expect(localStorage.getItem('branch_refresh_token')).toBe('test-refresh-token'); + }); + + it('getAccessToken returns the stored access token', async () => { + mockFetch(TEST_TOKENS); + const { result } = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.login('jane@example.com', 'password123'); + }); + + expect(result.current.getAccessToken()).toBe('test-access-token'); + }); + + it('logout clears user state and localStorage', async () => { + localStorage.setItem('branch_access_token', 'test-access-token'); + localStorage.setItem('branch_id_token', TEST_TOKENS.IdToken); + localStorage.setItem('branch_refresh_token', 'test-refresh-token'); + mockFetch({ success: true }); + + const { result } = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => expect(result.current.isAuthenticated).toBe(true)); + + await act(async () => { + await result.current.logout(); + }); + + expect(result.current.user).toBeNull(); + expect(result.current.isAuthenticated).toBe(false); + expect(localStorage.getItem('branch_access_token')).toBeNull(); + }); + + it('logout still clears state even if the server call fails', async () => { + localStorage.setItem('branch_access_token', 'test-access-token'); + localStorage.setItem('branch_id_token', TEST_TOKENS.IdToken); + global.fetch = jest.fn().mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => expect(result.current.isAuthenticated).toBe(true)); + + await act(async () => { + await result.current.logout(); + }); + + expect(result.current.user).toBeNull(); + expect(localStorage.getItem('branch_access_token')).toBeNull(); + }); + + it('login throws on invalid credentials', async () => { + mockFetch({ message: 'Invalid credentials' }, false); + const { result } = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await expect( + act(async () => { + await result.current.login('bad@example.com', 'wrong'); + }), + ).rejects.toThrow('Invalid credentials'); + + expect(result.current.isAuthenticated).toBe(false); + }); +}); diff --git a/apps/frontend/test/lib/api.test.ts b/apps/frontend/test/lib/api.test.ts new file mode 100644 index 00000000..b9422120 --- /dev/null +++ b/apps/frontend/test/lib/api.test.ts @@ -0,0 +1,51 @@ +import { apiFetch } from '@/lib/api'; + +function mockFetch(body: unknown, ok = true, status = 200) { + global.fetch = jest.fn().mockResolvedValue({ + ok, + status, + statusText: 'Bad Request', + json: jest.fn().mockResolvedValue(body), + } as unknown as Response); +} + +afterEach(() => jest.restoreAllMocks()); + +describe('apiFetch', () => { + it('calls fetch with the base URL prepended to the path', async () => { + mockFetch({ id: 1 }); + await apiFetch('/auth/login', { method: 'POST', body: '{}' }); + const url = (global.fetch as jest.Mock).mock.calls[0][0] as string; + expect(url).toMatch(/\/auth\/login$/); + }); + + it('adds Authorization header when token is provided', async () => { + mockFetch({ ok: true }); + await apiFetch('/auth/logout', { method: 'POST', token: 'my-token' }); + const headers = (global.fetch as jest.Mock).mock.calls[0][1].headers as Record; + expect(headers['Authorization']).toBe('Bearer my-token'); + }); + + it('does not add Authorization header when no token is given', async () => { + mockFetch({ ok: true }); + await apiFetch('/auth/health'); + const headers = (global.fetch as jest.Mock).mock.calls[0][1].headers as Record; + expect(headers['Authorization']).toBeUndefined(); + }); + + it('returns parsed JSON on success', async () => { + mockFetch({ userId: 42 }); + const result = await apiFetch<{ userId: number }>('/users/me'); + expect(result).toEqual({ userId: 42 }); + }); + + it('throws with the message from the error body on non-ok response', async () => { + mockFetch({ message: 'Invalid credentials' }, false, 401); + await expect(apiFetch('/auth/login')).rejects.toThrow('Invalid credentials'); + }); + + it('falls back to statusText when error body has no message', async () => { + mockFetch({}, false, 400); + await expect(apiFetch('/auth/login')).rejects.toThrow('Bad Request'); + }); +});