Implement auth middleware for API routes (P0.2)

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 <noreply@anthropic.com>
This commit is contained in:
2026-01-10 18:43:19 +00:00
parent caed11a445
commit 76a46439b3
5 changed files with 463 additions and 9 deletions

View File

@@ -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

View File

@@ -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<typeof vi.fn>;
const mockCreatePocketBaseClient = createPocketBaseClient as ReturnType<
typeof vi.fn
>;
const mockLoadAuthFromCookies = loadAuthFromCookies as ReturnType<typeof vi.fn>;
const mockIsAuthenticated = isAuthenticated as ReturnType<typeof vi.fn>;
const mockGetCurrentUser = getCurrentUser as ReturnType<typeof vi.fn>;
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");
});
});

View File

@@ -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<T = unknown> = (
request: NextRequest,
user: User,
context?: { params?: T },
) => Promise<NextResponse>;
/**
* 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<T = unknown>(
handler: AuthenticatedHandler<T>,
): (request: NextRequest, context?: { params?: T }) => Promise<NextResponse> {
return async (
request: NextRequest,
context?: { params?: T },
): Promise<NextResponse> => {
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 },
);
}
};
}

134
src/middleware.test.ts Normal file
View File

@@ -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);
});
});

75
src/middleware.ts Normal file
View File

@@ -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<NextResponse> {
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).*)",
],
};