From 98afa202187e33745f73b4a7461e255723c9856f Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Wed, 11 Feb 2026 19:08:51 -0800 Subject: [PATCH 1/7] security(web): remove Supabase publishable key from browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move all Supabase auth operations server-side so the anon key is never bundled into client JS. With RLS tightened (migration 016), direct browser queries are already blocked — this removes the unnecessary attack surface of exposing the key at all. - Add Server Actions for signInWithPassword, signInWithOtp, signOut - Add /api/auth/me route for cookie-based auth checks - Inject Authorization header in middleware for proxied API routes - Update login-form, sidebar, kindle page to use server-side auth - Remove client-side auth interceptor from axios client - Rename NEXT_PUBLIC_SUPABASE_* → server-only SUPABASE_* env vars - Delete browser Supabase client (lib/supabase/client.ts) - Set up Vitest for web package (22 unit tests, 20 integration tests) - Add Husky + lint-staged + Prettier pre-commit hook Co-Authored-By: Claude Opus 4.6 --- .husky/pre-commit | 1 + .prettierignore | 6 + package.json | 11 +- packages/web/.env.example | 6 +- packages/web/package.json | 11 +- .../web/src/app/(auth)/login/login-form.tsx | 102 ++---- .../web/src/app/api/auth/me/route.test.ts | 74 ++++ packages/web/src/app/api/auth/me/route.ts | 18 + packages/web/src/app/kindle/[token]/page.tsx | 38 +-- .../web/src/components/layout/sidebar.tsx | 12 +- packages/web/src/lib/api/client.ts | 16 +- packages/web/src/lib/auth/actions.test.ts | 130 +++++++ packages/web/src/lib/auth/actions.ts | 61 ++++ .../lib/auth/auth-flow.integration.test.ts | 231 +++++++++++++ .../lib/auth/no-key-leak.integration.test.ts | 83 +++++ packages/web/src/lib/supabase/client.ts | 8 - .../web/src/lib/supabase/middleware.test.ts | 173 ++++++++++ packages/web/src/lib/supabase/middleware.ts | 42 ++- packages/web/src/lib/supabase/server.ts | 34 +- packages/web/src/test/integration-setup.ts | 41 +++ packages/web/vitest.config.ts | 16 + packages/web/vitest.integration.config.ts | 18 + yarn.lock | 322 +++++++++++++++++- 23 files changed, 1285 insertions(+), 169 deletions(-) create mode 100644 .husky/pre-commit create mode 100644 .prettierignore create mode 100644 packages/web/src/app/api/auth/me/route.test.ts create mode 100644 packages/web/src/app/api/auth/me/route.ts create mode 100644 packages/web/src/lib/auth/actions.test.ts create mode 100644 packages/web/src/lib/auth/actions.ts create mode 100644 packages/web/src/lib/auth/auth-flow.integration.test.ts create mode 100644 packages/web/src/lib/auth/no-key-leak.integration.test.ts delete mode 100644 packages/web/src/lib/supabase/client.ts create mode 100644 packages/web/src/lib/supabase/middleware.test.ts create mode 100644 packages/web/src/test/integration-setup.ts create mode 100644 packages/web/vitest.config.ts create mode 100644 packages/web/vitest.integration.config.ts diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 00000000..2312dc58 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..6731faa1 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +node_modules +.next +dist +.yarn +coverage +*.lock diff --git a/package.json b/package.json index 596dec44..c86b457d 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "test": "yarn workspaces run test", "lint": "yarn workspaces run lint", "type-check": "yarn workspaces run type-check", - "clean": "yarn workspaces run clean && rm -rf node_modules && pm2 delete all" + "clean": "yarn workspaces run clean && rm -rf node_modules && pm2 delete all", + "prepare": "husky" }, "engines": { "node": ">=18.0.0", @@ -57,7 +58,13 @@ "author": "", "license": "MIT", "devDependencies": { - "pm2": "^6.0.14" + "husky": "^9.1.7", + "lint-staged": "^16.2.7", + "pm2": "^6.0.14", + "prettier": "^3.8.1" + }, + "lint-staged": { + "*.{ts,tsx,js,jsx,json,css,md}": "prettier --write" }, "packageManager": "yarn@4.12.0" } diff --git a/packages/web/.env.example b/packages/web/.env.example index b8f49bfa..008b9280 100644 --- a/packages/web/.env.example +++ b/packages/web/.env.example @@ -1,6 +1,6 @@ -# Supabase configuration -NEXT_PUBLIC_SUPABASE_URL=your_supabase_url -NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key +# Supabase configuration (server-only — not exposed to the browser) +SUPABASE_URL=your_supabase_url +SUPABASE_PUBLISHABLE_KEY=your_supabase_publishable_key # API server URL (MCP server with admin routes, default port 3001) API_URL=http://localhost:3001 diff --git a/packages/web/package.json b/packages/web/package.json index d916c6eb..1227eaac 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -8,7 +8,10 @@ "build": "next build", "start": "next start -p 3002", "lint": "next lint", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:integration": "vitest run --config vitest.integration.config.ts" }, "dependencies": { "@mantine/core": "^8.2.4", @@ -49,9 +52,13 @@ "@types/node": "^20.10.6", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^5.1.4", "autoprefixer": "^10.4.20", + "dotenv": "^17.2.4", "postcss": "^8.4.49", "tailwindcss": "^3.4.17", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "vite": "^7.3.1", + "vitest": "^4.0.18" } } diff --git a/packages/web/src/app/(auth)/login/login-form.tsx b/packages/web/src/app/(auth)/login/login-form.tsx index 6c478f93..0c5c76b9 100644 --- a/packages/web/src/app/(auth)/login/login-form.tsx +++ b/packages/web/src/app/(auth)/login/login-form.tsx @@ -5,26 +5,22 @@ import { useSearchParams, useRouter } from 'next/navigation'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { createClient } from '@/lib/supabase/client'; +import { signInWithPassword, signInWithOtp } from '@/lib/auth/actions'; type AuthMode = 'magic-link' | 'password'; // Map common error messages to user-friendly text function getErrorMessage(error: string): string { const errorMap: Record = { - 'auth': 'Authentication failed. Please try again.', + auth: 'Authentication failed. Please try again.', 'code challenge does not match previously saved code verifier': 'Your magic link expired or was opened in a different browser. Please request a new one using the same browser.', 'Email link is invalid or has expired': 'This magic link has expired. Please request a new one.', - 'No authentication code provided': - 'Invalid login link. Please request a new magic link.', - 'Invalid login credentials': - 'Invalid email or password. Please try again.', - 'Email not confirmed': - 'Please confirm your email address before signing in.', - 'rate limit': - 'Too many requests. Please try signing in with password instead.', + 'No authentication code provided': 'Invalid login link. Please request a new magic link.', + 'Invalid login credentials': 'Invalid email or password. Please try again.', + 'Email not confirmed': 'Please confirm your email address before signing in.', + 'rate limit': 'Too many requests. Please try signing in with password instead.', }; // Check for partial matches @@ -52,24 +48,6 @@ export default function LoginForm() { const mcpPendingId = searchParams.get('pending_id'); const isMcpAuth = !!(mcpRedirect && mcpPendingId); - // If already logged in and this is an MCP auth flow, redirect immediately - useEffect(() => { - if (!isMcpAuth) return; - - const checkExistingSession = async () => { - const supabase = createClient(); - const { data: { session } } = await supabase.auth.getSession(); - if (session?.access_token && session?.refresh_token) { - setMcpRedirecting(true); - redirectToMcp(); - } - // If session exists but refresh_token is missing, let user re-auth - // via the login form to get a fresh session with both tokens. - }; - - checkExistingSession(); - }, [isMcpAuth]); // eslint-disable-line react-hooks/exhaustive-deps - // Check for error in URL params on mount useEffect(() => { const error = searchParams.get('error'); @@ -86,50 +64,27 @@ export default function LoginForm() { : '/login'; window.history.replaceState({}, '', newUrl); } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Run once on mount — searchParams causes infinite loop when URL is modified - // Redirect to MCP callback with access token - const redirectToMcp = async () => { - if (!isMcpAuth) return; - - const supabase = createClient(); - const { data: { session } } = await supabase.auth.getSession(); - - if (session?.access_token && session?.refresh_token) { - const callbackUrl = new URL(mcpRedirect!); - callbackUrl.searchParams.set('pending_id', mcpPendingId!); - callbackUrl.searchParams.set('access_token', session.access_token); - callbackUrl.searchParams.set('refresh_token', session.refresh_token); - window.location.href = callbackUrl.toString(); - } - }; - const handleMagicLink = async () => { - const supabase = createClient(); - // For MCP auth, include the redirect info in the callback URL const callbackUrl = isMcpAuth ? `${window.location.origin}/auth/callback?mcp_redirect=${encodeURIComponent(mcpRedirect!)}&mcp_pending_id=${mcpPendingId}` : `${window.location.origin}/auth/callback`; - const { error } = await supabase.auth.signInWithOtp({ - email, - options: { - emailRedirectTo: callbackUrl, - }, - }); + const result = await signInWithOtp(email, callbackUrl); - if (error) { + if ('error' in result) { // If rate limited, suggest password mode - if (error.message.toLowerCase().includes('rate')) { + if (result.error.toLowerCase().includes('rate')) { setMessage({ type: 'error', - text: 'Rate limit reached. Please sign in with password instead.' + text: 'Rate limit reached. Please sign in with password instead.', }); setAuthMode('password'); } else { - setMessage({ type: 'error', text: error.message }); + setMessage({ type: 'error', text: result.error }); } } else { setMessage({ @@ -140,25 +95,18 @@ export default function LoginForm() { }; const handlePassword = async () => { - const supabase = createClient(); - const { error } = await supabase.auth.signInWithPassword({ - email, - password, - }); - - if (error) { - setMessage({ type: 'error', text: getErrorMessage(error.message) }); + const result = await signInWithPassword(email, password, mcpRedirect, mcpPendingId); + + if ('error' in result) { + setMessage({ type: 'error', text: getErrorMessage(result.error) }); + } else if ('mcpRedirectUrl' in result) { + // MCP flow: redirect to callback with tokens + setMcpRedirecting(true); + window.location.href = result.mcpRedirectUrl; } else { - // Successful login - if (isMcpAuth) { - // Show granting access view, then redirect to MCP callback - setMcpRedirecting(true); - await redirectToMcp(); - } else { - // Normal dashboard redirect - router.push('/'); - router.refresh(); - } + // Normal dashboard redirect + router.push('/'); + router.refresh(); } }; @@ -292,8 +240,8 @@ export default function LoginForm() { {isLoading ? 'Signing in...' : authMode === 'magic-link' - ? 'Send Magic Link' - : 'Sign In'} + ? 'Send Magic Link' + : 'Sign In'} diff --git a/packages/web/src/app/api/auth/me/route.test.ts b/packages/web/src/app/api/auth/me/route.test.ts new file mode 100644 index 00000000..143691cc --- /dev/null +++ b/packages/web/src/app/api/auth/me/route.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock Supabase server client +const mockGetUser = vi.fn(); + +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn().mockResolvedValue({ + auth: { + getUser: () => mockGetUser(), + }, + }), +})); + +import { GET } from './route'; + +describe('GET /api/auth/me', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns authenticated user when session exists', async () => { + mockGetUser.mockResolvedValue({ + data: { + user: { + id: 'user-uuid-123', + email: 'user@test.com', + }, + }, + }); + + const response = await GET(); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ + authenticated: true, + user: { id: 'user-uuid-123', email: 'user@test.com' }, + }); + }); + + it('returns 401 when no user session exists', async () => { + mockGetUser.mockResolvedValue({ + data: { user: null }, + }); + + const response = await GET(); + const body = await response.json(); + + expect(response.status).toBe(401); + expect(body).toEqual({ authenticated: false }); + }); + + it('does not leak extra user fields', async () => { + mockGetUser.mockResolvedValue({ + data: { + user: { + id: 'user-uuid-123', + email: 'user@test.com', + role: 'admin', + app_metadata: { provider: 'email' }, + user_metadata: { full_name: 'Test User' }, + }, + }, + }); + + const response = await GET(); + const body = await response.json(); + + expect(body.user).toEqual({ id: 'user-uuid-123', email: 'user@test.com' }); + expect(body.user).not.toHaveProperty('role'); + expect(body.user).not.toHaveProperty('app_metadata'); + expect(body.user).not.toHaveProperty('user_metadata'); + }); +}); diff --git a/packages/web/src/app/api/auth/me/route.ts b/packages/web/src/app/api/auth/me/route.ts new file mode 100644 index 00000000..e9d45c24 --- /dev/null +++ b/packages/web/src/app/api/auth/me/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; + +export async function GET() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ authenticated: false }, { status: 401 }); + } + + return NextResponse.json({ + authenticated: true, + user: { id: user.id, email: user.email }, + }); +} diff --git a/packages/web/src/app/kindle/[token]/page.tsx b/packages/web/src/app/kindle/[token]/page.tsx index 09596303..fb39852c 100644 --- a/packages/web/src/app/kindle/[token]/page.tsx +++ b/packages/web/src/app/kindle/[token]/page.tsx @@ -6,7 +6,6 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Sparkles } from 'lucide-react'; -import { createClient } from '@/lib/supabase/client'; import { apiPost } from '@/lib/api'; interface TokenInfo { @@ -52,12 +51,15 @@ export default function KindleLandingPage() { fetchToken(); }, [token]); - // Check auth status + // Check auth status via server endpoint useEffect(() => { async function checkAuth() { - const supabase = createClient(); - const { data: { user } } = await supabase.auth.getUser(); - setIsAuthenticated(!!user); + try { + const res = await fetch('/api/auth/me'); + setIsAuthenticated(res.ok); + } catch { + setIsAuthenticated(false); + } } checkAuth(); }, []); @@ -71,7 +73,9 @@ export default function KindleLandingPage() { setRedeeming(true); try { - const result = await apiPost<{ kindleId: string; agentId: string }>('/api/kindle/redeem', { token }); + const result = await apiPost<{ kindleId: string; agentId: string }>('/api/kindle/redeem', { + token, + }); // Redirect to onboarding chat router.push(`/kindle/onboarding?kindleId=${result.kindleId}&agentId=${result.agentId}`); } catch (err) { @@ -123,8 +127,8 @@ export default function KindleLandingPage() { {parentName && (

- {parentName} wants to kindle a new SB for you — one that shares - their core values but will grow to be uniquely yours. + {parentName} wants to kindle a new SB for you — one that shares their core values but + will grow to be uniquely yours.

)} @@ -145,24 +149,18 @@ export default function KindleLandingPage() {

- What happens next: You'll have a conversation with - your nascent SB. They'll ask a few questions about what matters to - you, explore your values together, and then choose a name. After - that, your SB is yours. + What happens next: You'll have a conversation with your nascent + SB. They'll ask a few questions about what matters to you, explore your values + together, and then choose a name. After that, your SB is yours.

- {!isAuthenticated && ( diff --git a/packages/web/src/components/layout/sidebar.tsx b/packages/web/src/components/layout/sidebar.tsx index b4f4d3e7..84da97ac 100644 --- a/packages/web/src/components/layout/sidebar.tsx +++ b/packages/web/src/components/layout/sidebar.tsx @@ -16,8 +16,7 @@ import { MessageSquare, } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { createClient } from '@/lib/supabase/client'; -import { useRouter } from 'next/navigation'; +import { signOut } from '@/lib/auth/actions'; const navigation = [ { name: 'Dashboard', href: '/', icon: Home }, @@ -34,13 +33,6 @@ const navigation = [ export function Sidebar() { const pathname = usePathname(); - const router = useRouter(); - - const handleSignOut = async () => { - const supabase = createClient(); - await supabase.auth.signOut(); - router.push('/login'); - }; return (
@@ -71,7 +63,7 @@ export function Sidebar() {