From 76a46439b3379c06e3cb98d3043d49e8814900c3 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Sat, 10 Jan 2026 18:43:19 +0000 Subject: [PATCH] Implement auth middleware for API routes (P0.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add authentication infrastructure for protected routes: - withAuth() wrapper for API route handlers (src/lib/auth-middleware.ts) - Next.js middleware for page protection (src/middleware.ts) withAuth() loads auth from cookies, validates session, and passes user context to handlers. Returns 401 for unauthenticated requests. Page middleware redirects unauthenticated users to /login, while allowing public routes (/login), API routes (handled separately), and static assets through. Tests: 18 new tests (6 for withAuth, 12 for page middleware) Total test count: 60 tests passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- IMPLEMENTATION_PLAN.md | 23 +++-- src/lib/auth-middleware.test.ts | 164 ++++++++++++++++++++++++++++++++ src/lib/auth-middleware.ts | 76 +++++++++++++++ src/middleware.test.ts | 134 ++++++++++++++++++++++++++ src/middleware.ts | 75 +++++++++++++++ 5 files changed, 463 insertions(+), 9 deletions(-) create mode 100644 src/lib/auth-middleware.test.ts create mode 100644 src/lib/auth-middleware.ts create mode 100644 src/middleware.test.ts create mode 100644 src/middleware.ts diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index fa55f74..c4d29c4 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -15,10 +15,12 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `decision-engine.ts` | **COMPLETE** | 8 priority rules + override handling with `getDecisionWithOverrides()`, 24 tests | | `garmin.ts` | **Minimal (~30%)** | Has fetchGarminData, isTokenExpired, daysUntilExpiry. **MISSING: fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes** | | `pocketbase.ts` | **COMPLETE** | 9 tests covering `createPocketBaseClient()`, `isAuthenticated()`, `getCurrentUser()`, `loadAuthFromCookies()` | +| `auth-middleware.ts` | **COMPLETE** | 6 tests covering `withAuth()` wrapper for API route protection | +| `middleware.ts` (Next.js) | **COMPLETE** | 12 tests covering page protection, redirects to login | ### Missing Infrastructure Files (CONFIRMED NOT EXIST) -- `src/lib/auth-middleware.ts` - Does NOT exist, needs creation -- `src/app/middleware.ts` - Does NOT exist, needs creation +- ~~`src/lib/auth-middleware.ts`~~ - **CREATED** in P0.2 +- ~~`src/middleware.ts`~~ - **CREATED** in P0.2 ### API Routes (12 total) | Route | Status | Notes | @@ -66,6 +68,8 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `src/lib/cycle.test.ts` | **EXISTS** - 9 tests | | `src/lib/decision-engine.test.ts` | **EXISTS** - 24 tests (8 algorithmic rules + 16 override scenarios) | | `src/lib/pocketbase.test.ts` | **EXISTS** - 9 tests (auth helpers, cookie loading) | +| `src/lib/auth-middleware.test.ts` | **EXISTS** - 6 tests (withAuth wrapper, error handling) | +| `src/middleware.test.ts` | **EXISTS** - 12 tests (page protection, public routes, static assets) | | `src/lib/nutrition.test.ts` | **MISSING** | | `src/lib/email.test.ts` | **MISSING** | | `src/lib/ics.test.ts` | **MISSING** | @@ -96,13 +100,14 @@ These must be completed first - nothing else works without them. - **Why:** Every protected route and page depends on these helpers - **Blocking:** P0.2, P0.4, P1.1-P1.7, P2.2-P2.13 -### P0.2: Auth Middleware for API Routes -- [ ] Create reusable auth middleware for protected API endpoints +### P0.2: Auth Middleware for API Routes ✅ COMPLETE +- [x] Create reusable auth middleware for protected API endpoints - **Files:** - - `src/lib/auth-middleware.ts` - **CREATE** `withAuth()` wrapper for route handlers - - `src/app/middleware.ts` - **CREATE** Next.js middleware for page protection + - `src/lib/auth-middleware.ts` - Added `withAuth()` wrapper for route handlers + - `src/middleware.ts` - Added Next.js middleware for page protection - **Tests:** - - `src/lib/auth-middleware.test.ts` - Test unauthorized rejection, user context passing + - `src/lib/auth-middleware.test.ts` - 6 tests covering unauthorized rejection, user context passing, error handling + - `src/middleware.test.ts` - 12 tests covering protected routes, public routes, API routes, static assets - **Why:** All API routes except `/api/calendar/[userId]/[token].ics` and `/api/cron/*` require auth - **Depends On:** P0.1 - **Blocking:** P0.4, P1.1-P1.5 @@ -477,8 +482,8 @@ P2.14 Mini calendar *Bugs and inconsistencies found during implementation* -- [ ] `src/lib/auth-middleware.ts` does not exist - must be created in P0.2 -- [ ] `src/app/middleware.ts` does not exist - must be created in P0.2 +- [x] ~~`src/lib/auth-middleware.ts` does not exist~~ - CREATED in P0.2 +- [x] ~~`src/middleware.ts` does not exist~~ - CREATED in P0.2 - [ ] `garmin.ts` is only ~30% complete - missing specific biometric fetchers - [x] ~~`pocketbase.ts` missing all auth helper functions~~ - FIXED in P0.1 diff --git a/src/lib/auth-middleware.test.ts b/src/lib/auth-middleware.test.ts new file mode 100644 index 0000000..3274040 --- /dev/null +++ b/src/lib/auth-middleware.test.ts @@ -0,0 +1,164 @@ +// ABOUTME: Unit tests for API route authentication middleware. +// ABOUTME: Tests withAuth wrapper for protected route handlers. +import { type NextRequest, NextResponse } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { withAuth } from "./auth-middleware"; + +// Mock the pocketbase module +vi.mock("./pocketbase", () => ({ + createPocketBaseClient: vi.fn(), + loadAuthFromCookies: vi.fn(), + isAuthenticated: vi.fn(), + getCurrentUser: vi.fn(), +})); + +// Mock next/headers +vi.mock("next/headers", () => ({ + cookies: vi.fn(), +})); + +import { cookies } from "next/headers"; +import { + createPocketBaseClient, + getCurrentUser, + isAuthenticated, + loadAuthFromCookies, +} from "./pocketbase"; + +const mockCookies = cookies as ReturnType; +const mockCreatePocketBaseClient = createPocketBaseClient as ReturnType< + typeof vi.fn +>; +const mockLoadAuthFromCookies = loadAuthFromCookies as ReturnType; +const mockIsAuthenticated = isAuthenticated as ReturnType; +const mockGetCurrentUser = getCurrentUser as ReturnType; + +describe("withAuth", () => { + const mockUser = { + id: "user123", + email: "test@example.com", + garminConnected: false, + garminOauth1Token: "", + garminOauth2Token: "", + garminTokenExpiresAt: new Date(), + calendarToken: "cal-token", + lastPeriodDate: new Date("2025-01-01"), + cycleLength: 31, + notificationTime: "07:00", + timezone: "UTC", + activeOverrides: [], + created: new Date(), + updated: new Date(), + }; + + const mockPbClient = { + authStore: { + isValid: true, + model: mockUser, + }, + }; + + const mockCookieStore = { + get: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockCookies.mockResolvedValue(mockCookieStore); + mockCreatePocketBaseClient.mockReturnValue(mockPbClient); + }); + + it("returns 401 when not authenticated", async () => { + mockIsAuthenticated.mockReturnValue(false); + + const handler = vi.fn(); + const wrappedHandler = withAuth(handler); + + const mockRequest = {} as NextRequest; + const response = await wrappedHandler(mockRequest); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.error).toBe("Unauthorized"); + expect(handler).not.toHaveBeenCalled(); + }); + + it("calls handler with user when authenticated", async () => { + mockIsAuthenticated.mockReturnValue(true); + mockGetCurrentUser.mockReturnValue(mockUser); + + const handler = vi + .fn() + .mockResolvedValue(NextResponse.json({ data: "success" })); + const wrappedHandler = withAuth(handler); + + const mockRequest = {} as NextRequest; + const response = await wrappedHandler(mockRequest); + + expect(response.status).toBe(200); + expect(handler).toHaveBeenCalledWith(mockRequest, mockUser, undefined); + }); + + it("loads auth from cookies before checking authentication", async () => { + mockIsAuthenticated.mockReturnValue(true); + mockGetCurrentUser.mockReturnValue(mockUser); + + const handler = vi.fn().mockResolvedValue(NextResponse.json({})); + const wrappedHandler = withAuth(handler); + + await wrappedHandler({} as NextRequest); + + expect(mockCreatePocketBaseClient).toHaveBeenCalled(); + expect(mockCookies).toHaveBeenCalled(); + expect(mockLoadAuthFromCookies).toHaveBeenCalledWith( + mockPbClient, + mockCookieStore, + ); + expect(mockIsAuthenticated).toHaveBeenCalledWith(mockPbClient); + }); + + it("returns 401 when getCurrentUser returns null", async () => { + mockIsAuthenticated.mockReturnValue(true); + mockGetCurrentUser.mockReturnValue(null); + + const handler = vi.fn(); + const wrappedHandler = withAuth(handler); + + const response = await wrappedHandler({} as NextRequest); + + expect(response.status).toBe(401); + expect(handler).not.toHaveBeenCalled(); + }); + + it("passes route params to handler when provided", async () => { + mockIsAuthenticated.mockReturnValue(true); + mockGetCurrentUser.mockReturnValue(mockUser); + + const handler = vi.fn().mockResolvedValue(NextResponse.json({})); + const wrappedHandler = withAuth(handler); + + const mockRequest = {} as NextRequest; + const mockParams = { id: "123" }; + + await wrappedHandler(mockRequest, { params: mockParams }); + + expect(handler).toHaveBeenCalledWith(mockRequest, mockUser, { + params: mockParams, + }); + }); + + it("handles handler errors gracefully", async () => { + mockIsAuthenticated.mockReturnValue(true); + mockGetCurrentUser.mockReturnValue(mockUser); + + const handler = vi.fn().mockRejectedValue(new Error("Handler error")); + const wrappedHandler = withAuth(handler); + + const response = await wrappedHandler({} as NextRequest); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.error).toBe("Internal server error"); + }); +}); diff --git a/src/lib/auth-middleware.ts b/src/lib/auth-middleware.ts new file mode 100644 index 0000000..76e3b58 --- /dev/null +++ b/src/lib/auth-middleware.ts @@ -0,0 +1,76 @@ +// ABOUTME: Authentication middleware wrapper for protected API routes. +// ABOUTME: Provides withAuth HOF that validates session and injects user context. + +import { cookies } from "next/headers"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +import type { User } from "@/types"; + +import { + createPocketBaseClient, + getCurrentUser, + isAuthenticated, + loadAuthFromCookies, +} from "./pocketbase"; + +/** + * Route handler function type that receives the authenticated user. + */ +export type AuthenticatedHandler = ( + request: NextRequest, + user: User, + context?: { params?: T }, +) => Promise; + +/** + * Higher-order function that wraps an API route handler with authentication. + * Loads auth from cookies, validates the session, and passes the user to the handler. + * + * @param handler - The route handler that requires authentication + * @returns A wrapped handler that checks auth before calling the original handler + * + * @example + * ```ts + * export const GET = withAuth(async (request, user) => { + * return NextResponse.json({ email: user.email }); + * }); + * ``` + */ +export function withAuth( + handler: AuthenticatedHandler, +): (request: NextRequest, context?: { params?: T }) => Promise { + return async ( + request: NextRequest, + context?: { params?: T }, + ): Promise => { + try { + // Create a fresh PocketBase client for this request + const pb = createPocketBaseClient(); + + // Load auth state from cookies + const cookieStore = await cookies(); + loadAuthFromCookies(pb, cookieStore); + + // Check if the user is authenticated + if (!isAuthenticated(pb)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Get the current user + const user = getCurrentUser(pb); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Call the original handler with the user context + return await handler(request, user, context); + } catch (error) { + console.error("Auth middleware error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } + }; +} diff --git a/src/middleware.test.ts b/src/middleware.test.ts new file mode 100644 index 0000000..ee7a8df --- /dev/null +++ b/src/middleware.test.ts @@ -0,0 +1,134 @@ +// ABOUTME: Unit tests for Next.js middleware page protection. +// ABOUTME: Tests that protected routes redirect to login when unauthenticated. +import type { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { config, middleware } from "./middleware"; + +describe("middleware", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + function createMockRequest(url: string, cookieValue?: string): NextRequest { + const request = { + nextUrl: new URL(url, "http://localhost:3000"), + url: `http://localhost:3000${url}`, + cookies: { + get: vi + .fn() + .mockReturnValue( + cookieValue ? { name: "pb_auth", value: cookieValue } : undefined, + ), + }, + } as unknown as NextRequest; + return request; + } + + describe("protected routes", () => { + it("redirects to /login when pb_auth cookie is missing", async () => { + const request = createMockRequest("/"); + const response = await middleware(request); + + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe( + "http://localhost:3000/login", + ); + }); + + it("redirects to /login when pb_auth cookie is empty", async () => { + const request = createMockRequest("/", ""); + const response = await middleware(request); + + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe( + "http://localhost:3000/login", + ); + }); + + it("allows access when pb_auth cookie is present", async () => { + const request = createMockRequest("/", "valid-token"); + const response = await middleware(request); + + expect(response.status).toBe(200); + }); + + it("redirects /settings to /login when unauthenticated", async () => { + const request = createMockRequest("/settings"); + const response = await middleware(request); + + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe( + "http://localhost:3000/login", + ); + }); + + it("redirects /calendar to /login when unauthenticated", async () => { + const request = createMockRequest("/calendar"); + const response = await middleware(request); + + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe( + "http://localhost:3000/login", + ); + }); + }); + + describe("public routes", () => { + it("allows /login without authentication", async () => { + const request = createMockRequest("/login"); + const response = await middleware(request); + + expect(response.status).toBe(200); + }); + + it("allows /login even with authentication cookie", async () => { + const request = createMockRequest("/login", "valid-token"); + const response = await middleware(request); + + // Could redirect authenticated users away from login, but for now just allow + expect(response.status).toBe(200); + }); + }); + + describe("API routes", () => { + it("skips middleware for /api routes (handled by route-level auth)", async () => { + const request = createMockRequest("/api/user"); + const response = await middleware(request); + + // API routes are not processed by this middleware + expect(response.status).toBe(200); + }); + + it("skips middleware for /api/calendar ICS endpoint", async () => { + const request = createMockRequest("/api/calendar/user123/token.ics"); + const response = await middleware(request); + + expect(response.status).toBe(200); + }); + }); + + describe("static assets", () => { + it("skips middleware for _next static assets", async () => { + const request = createMockRequest("/_next/static/chunk.js"); + const response = await middleware(request); + + expect(response.status).toBe(200); + }); + + it("skips middleware for favicon", async () => { + const request = createMockRequest("/favicon.ico"); + const response = await middleware(request); + + expect(response.status).toBe(200); + }); + }); +}); + +describe("middleware config", () => { + it("exports a matcher configuration", () => { + expect(config).toBeDefined(); + expect(config.matcher).toBeDefined(); + expect(Array.isArray(config.matcher)).toBe(true); + }); +}); diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..f7b1524 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,75 @@ +// ABOUTME: Next.js middleware for page route protection. +// ABOUTME: Redirects unauthenticated users to login for protected pages. +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +/** + * Public paths that don't require authentication. + */ +const PUBLIC_PATHS = ["/login"]; + +/** + * Paths to skip middleware entirely (API routes, static assets). + */ +const SKIP_PATHS = ["/api", "/_next", "/favicon.ico"]; + +/** + * Checks if a request path should skip middleware. + */ +function shouldSkipMiddleware(pathname: string): boolean { + return SKIP_PATHS.some((path) => pathname.startsWith(path)); +} + +/** + * Checks if a request path is public (doesn't require auth). + */ +function isPublicPath(pathname: string): boolean { + return PUBLIC_PATHS.some((path) => pathname === path); +} + +/** + * Next.js middleware function for page protection. + * Checks for pb_auth cookie and redirects to login if missing. + */ +export async function middleware(request: NextRequest): Promise { + const { pathname } = request.nextUrl; + + // Skip middleware for API routes and static assets + if (shouldSkipMiddleware(pathname)) { + return NextResponse.next(); + } + + // Allow public paths without authentication + if (isPublicPath(pathname)) { + return NextResponse.next(); + } + + // Check for authentication cookie + const authCookie = request.cookies.get("pb_auth"); + + // If no valid auth cookie, redirect to login + if (!authCookie || !authCookie.value) { + const loginUrl = new URL("/login", request.nextUrl.origin); + return NextResponse.redirect(loginUrl); + } + + // User has auth cookie, allow through + return NextResponse.next(); +} + +/** + * Matcher configuration for Next.js middleware. + * Matches all paths except static files and public assets. + */ +export const config = { + matcher: [ + /* + * Match all request paths except: + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * - public folder + */ + "/((?!_next/static|_next/image|favicon.ico|public).*)", + ], +};