diff --git a/package-lock.json b/package-lock.json index 7663871..6c7d701 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@hono/node-server": "^1.13.7", "@supabase/supabase-js": "^2.49.4", + "dotenv": "^17.4.2", "hono": "^4.7.7", "stripe": "^17.7.0" }, @@ -1455,6 +1456,18 @@ "node": ">=6" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/package.json b/package.json index 391115d..aecc4d7 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dependencies": { "@hono/node-server": "^1.13.7", "@supabase/supabase-js": "^2.49.4", + "dotenv": "^17.4.2", "hono": "^4.7.7", "stripe": "^17.7.0" }, diff --git a/src/index.ts b/src/index.ts index e7ce7d8..cba25b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import 'dotenv/config' import { Hono } from 'hono' import { serve } from '@hono/node-server' import authRoutes from './routes/auth' diff --git a/src/middleware/__tests__/auth.test.ts b/src/middleware/__tests__/auth.test.ts new file mode 100644 index 0000000..9bbb0be --- /dev/null +++ b/src/middleware/__tests__/auth.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Hono } from 'hono' +import { authMiddleware } from '../auth' +import type { AppVariables } from '../auth' + +vi.mock('../../lib/supabase', () => ({ + supabase: { + auth: { + getUser: vi.fn(), + }, + from: vi.fn(), + }, +})) + +import { supabase } from '../../lib/supabase' + +const mockProfile = { + id: 'user-123', + email: 'test@example.com', + plan: 'free', + simulations_used: 2, + stripe_customer_id: null, + stripe_subscription_id: null, + plan_expires_at: null, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', +} + +function createTestApp() { + const app = new Hono<{ Variables: AppVariables }>() + app.post('/auth/verify-token', authMiddleware, (c) => { + const profile = c.get('profile') + return c.json({ id: profile.id, email: profile.email, plan: profile.plan }) + }) + return app +} + +describe('authMiddleware', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('retourne 401 si le header Authorization est absent', async () => { + const app = createTestApp() + const res = await app.request('/auth/verify-token', { method: 'POST' }) + expect(res.status).toBe(401) + const body = await res.json() + expect(body.code).toBe('AUTH_REQUIRED') + expect(body.error).toBe(true) + }) + + it('retourne 401 si le header Authorization ne commence pas par Bearer', async () => { + const app = createTestApp() + const res = await app.request('/auth/verify-token', { + method: 'POST', + headers: { Authorization: 'Basic abc123' }, + }) + expect(res.status).toBe(401) + const body = await res.json() + expect(body.code).toBe('AUTH_REQUIRED') + }) + + it('retourne 401 si le JWT est invalide (erreur Supabase)', async () => { + vi.mocked(supabase.auth.getUser).mockResolvedValueOnce({ + data: { user: null }, + error: { message: 'Invalid JWT' } as any, + }) + const app = createTestApp() + const res = await app.request('/auth/verify-token', { + method: 'POST', + headers: { Authorization: 'Bearer invalid-token' }, + }) + expect(res.status).toBe(401) + const body = await res.json() + expect(body.code).toBe('AUTH_REQUIRED') + }) + + it('retourne 401 si Supabase retourne user null sans erreur', async () => { + vi.mocked(supabase.auth.getUser).mockResolvedValueOnce({ + data: { user: null }, + error: null, + }) + const app = createTestApp() + const res = await app.request('/auth/verify-token', { + method: 'POST', + headers: { Authorization: 'Bearer some-token' }, + }) + expect(res.status).toBe(401) + const body = await res.json() + expect(body.code).toBe('AUTH_REQUIRED') + }) + + it('retourne 401 si le profil est introuvable en base', async () => { + vi.mocked(supabase.auth.getUser).mockResolvedValueOnce({ + data: { user: { id: 'user-123', email: 'test@example.com' } as any }, + error: null, + }) + vi.mocked(supabase.from).mockReturnValueOnce({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + single: vi.fn(() => ({ data: null, error: { message: 'Not found' } })), + })), + })), + } as any) + const app = createTestApp() + const res = await app.request('/auth/verify-token', { + method: 'POST', + headers: { Authorization: 'Bearer valid-token' }, + }) + expect(res.status).toBe(401) + const body = await res.json() + expect(body.code).toBe('AUTH_REQUIRED') + }) + + it('passe au handler suivant avec profil complet si JWT valide', async () => { + vi.mocked(supabase.auth.getUser).mockResolvedValueOnce({ + data: { user: { id: 'user-123', email: 'test@example.com' } as any }, + error: null, + }) + vi.mocked(supabase.from).mockReturnValueOnce({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + single: vi.fn(() => ({ data: mockProfile, error: null })), + })), + })), + } as any) + const app = createTestApp() + const res = await app.request('/auth/verify-token', { + method: 'POST', + headers: { Authorization: 'Bearer valid-token' }, + }) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.id).toBe('user-123') + expect(body.email).toBe('test@example.com') + expect(body.plan).toBe('free') + }) + + it('passe au handler suivant avec un profil premium', async () => { + vi.mocked(supabase.auth.getUser).mockResolvedValueOnce({ + data: { user: { id: 'user-456', email: 'premium@example.com' } as any }, + error: null, + }) + vi.mocked(supabase.from).mockReturnValueOnce({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + single: vi.fn(() => ({ + data: { ...mockProfile, id: 'user-456', email: 'premium@example.com', plan: 'premium' }, + error: null, + })), + })), + })), + } as any) + const app = createTestApp() + const res = await app.request('/auth/verify-token', { + method: 'POST', + headers: { Authorization: 'Bearer premium-token' }, + }) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.plan).toBe('premium') + }) +}) diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..ecf25fa --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,82 @@ +import type { Context, Next } from 'hono' +import { supabase } from '../lib/supabase' + +export type AuthUser = { + id: string + email: string +} + +export type AuthProfile = { + id: string + email: string + plan: string + simulations_used: number + stripe_customer_id: string | null + stripe_subscription_id: string | null + plan_expires_at: string | null + created_at: string + updated_at: string +} + +export type AppVariables = { + user: AuthUser + profile: AuthProfile +} + +export async function authMiddleware( + c: Context<{ Variables: AppVariables }>, + next: Next +) { + const authHeader = c.req.header('Authorization') + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return c.json( + { + error: true, + code: 'AUTH_REQUIRED', + message: 'Authentification requise.', + }, + 401 + ) + } + + const token = authHeader.slice(7) + + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(token) + + if (authError || !user) { + return c.json( + { + error: true, + code: 'AUTH_REQUIRED', + message: 'Token invalide ou expiré.', + }, + 401 + ) + } + + const { data: profile, error: profileError } = await supabase + .from('profiles') + .select('*') + .eq('id', user.id) + .single() + + if (profileError || !profile) { + return c.json( + { + error: true, + code: 'AUTH_REQUIRED', + message: 'Profil utilisateur introuvable.', + }, + 401 + ) + } + + c.set('user', { id: user.id, email: user.email ?? '' }) + c.set('profile', profile as AuthProfile) + + await next() +} diff --git a/src/routes/auth.ts b/src/routes/auth.ts new file mode 100644 index 0000000..8cf5447 --- /dev/null +++ b/src/routes/auth.ts @@ -0,0 +1,21 @@ +import { Hono } from 'hono' +import { authMiddleware } from '../middleware/auth' +import type { AppVariables } from '../middleware/auth' + +const auth = new Hono<{ Variables: AppVariables }>() + +auth.post('/verify-token', authMiddleware, (c) => { + const profile = c.get('profile') + return c.json( + { + id: profile.id, + email: profile.email, + plan: profile.plan, + simulations_used: profile.simulations_used, + plan_expires_at: profile.plan_expires_at, + }, + 200 + ) +}) + +export default auth