diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx index 7d47b0b..59ef4a2 100644 --- a/apps/web/src/app/(app)/layout.tsx +++ b/apps/web/src/app/(app)/layout.tsx @@ -1,6 +1,7 @@ import { redirect } from "next/navigation"; import { AppShell } from "~/components/layout/AppShell.js"; import { MfaPromptBanner } from "~/components/security/MfaPromptBanner.js"; +import { SessionGuard } from "~/components/security/SessionGuard.js"; import { auth } from "~/server/auth.js"; const MFA_PROMPT_ROLES = new Set(["ADMIN", "MANAGER"]); @@ -17,6 +18,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod return ( + {MFA_PROMPT_ROLES.has(userRole) && } {children} diff --git a/apps/web/src/components/security/SessionGuard.tsx b/apps/web/src/components/security/SessionGuard.tsx new file mode 100644 index 0000000..3a56198 --- /dev/null +++ b/apps/web/src/components/security/SessionGuard.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +// Watches the client-side session state and redirects to /auth/signin +// when the session expires mid-SPA-session (without a full page reload). +// Rendered in the authenticated layout — never visible to the user. +export function SessionGuard() { + const { status } = useSession(); + const router = useRouter(); + + useEffect(() => { + if (status === "unauthenticated") { + router.replace("/auth/signin"); + } + }, [status, router]); + + return null; +} diff --git a/apps/web/src/lib/trpc/provider.tsx b/apps/web/src/lib/trpc/provider.tsx index 7c4b340..cea1419 100644 --- a/apps/web/src/lib/trpc/provider.tsx +++ b/apps/web/src/lib/trpc/provider.tsx @@ -1,6 +1,7 @@ "use client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { TRPCClientError } from "@trpc/client"; import { httpBatchLink, loggerLink } from "@trpc/client"; import { SessionProvider } from "next-auth/react"; import { useState } from "react"; @@ -12,6 +13,17 @@ function getBaseUrl() { return `http://localhost:${process.env["PORT"] ?? 3100}`; } +function redirectToSignIn(): void { + if (typeof window !== "undefined") { + window.location.replace("/auth/signin"); + } +} + +function isUnauthorizedTrpcError(error: unknown): boolean { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return error instanceof TRPCClientError && (error as any).data?.code === "UNAUTHORIZED"; +} + function isIgnorableTransportError(error: unknown): boolean { const message = typeof error === "object" && error !== null && "message" in error @@ -25,12 +37,26 @@ export function TRPCProvider({ children }: { children: React.ReactNode }) { const [queryClient] = useState( () => new QueryClient({ + queryCache: new QueryCache({ + onError: (error) => { + if (isUnauthorizedTrpcError(error)) redirectToSignIn(); + }, + }), + mutationCache: new MutationCache({ + onError: (error) => { + if (isUnauthorizedTrpcError(error)) redirectToSignIn(); + }, + }), defaultOptions: { queries: { staleTime: 60_000, // 60 seconds — reduces refetches on navigation refetchOnWindowFocus: false, refetchOnReconnect: false, - retry: 1, + retry: (failureCount, error) => { + // Never retry UNAUTHORIZED — redirect immediately instead + if (isUnauthorizedTrpcError(error)) return false; + return failureCount < 1; + }, }, }, }), @@ -55,7 +81,7 @@ export function TRPCProvider({ children }: { children: React.ReactNode }) { ); return ( - + {children} diff --git a/apps/web/src/middleware.test.ts b/apps/web/src/middleware.test.ts index a89d5ff..e6cfafc 100644 --- a/apps/web/src/middleware.test.ts +++ b/apps/web/src/middleware.test.ts @@ -1,13 +1,20 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; import { NextRequest } from "next/server"; -// Web Crypto is available in the test environment (Node 20+) +// Simulate an authenticated session so the middleware does not redirect +// and CSP headers are set on every response. +vi.mock("./server/auth-edge.js", () => ({ + auth: (handler: (req: NextRequest & { auth: object | null }) => unknown) => + (req: NextRequest) => + handler(Object.assign(req, { auth: { user: { id: "test-user", email: "test@test.com" } } })), +})); async function importMiddleware(nodeEnv: string) { vi.stubEnv("NODE_ENV", nodeEnv); vi.resetModules(); const mod = await import("./middleware.js"); - return mod.middleware; + // middleware is the default export (wrapped by auth()) + return mod.default as (req: NextRequest) => Promise; } describe("middleware — Content-Security-Policy", () => { @@ -19,16 +26,16 @@ describe("middleware — Content-Security-Policy", () => { it("sets a Content-Security-Policy header on every response", async () => { const middleware = await importMiddleware("production"); const req = new NextRequest("http://localhost:3100/"); - const res = middleware(req); + const res = await middleware(req); expect(res.headers.get("Content-Security-Policy")).toBeTruthy(); }); it("production: script-src contains a nonce and does NOT contain unsafe-inline or unsafe-eval", async () => { const middleware = await importMiddleware("production"); const req = new NextRequest("http://localhost:3100/dashboard"); - const res = middleware(req); + const res = await middleware(req); const csp = res.headers.get("Content-Security-Policy") ?? ""; - const scriptSrc = csp.split(";").find((d) => d.trim().startsWith("script-src")) ?? ""; + const scriptSrc = csp.split(";").find((d: string) => d.trim().startsWith("script-src")) ?? ""; expect(scriptSrc).toMatch(/'nonce-[A-Za-z0-9+/=]+'/); expect(scriptSrc).not.toContain("'unsafe-inline'"); expect(scriptSrc).not.toContain("'unsafe-eval'"); @@ -36,8 +43,8 @@ describe("middleware — Content-Security-Policy", () => { it("production: each request gets a unique nonce", async () => { const middleware = await importMiddleware("production"); - const res1 = middleware(new NextRequest("http://localhost:3100/a")); - const res2 = middleware(new NextRequest("http://localhost:3100/b")); + const res1 = await middleware(new NextRequest("http://localhost:3100/a")); + const res2 = await middleware(new NextRequest("http://localhost:3100/b")); const nonce1 = res1.headers.get("Content-Security-Policy")?.match(/'nonce-([^']+)'/)?.[1]; const nonce2 = res2.headers.get("Content-Security-Policy")?.match(/'nonce-([^']+)'/)?.[1]; expect(nonce1).toBeTruthy(); @@ -48,7 +55,7 @@ describe("middleware — Content-Security-Policy", () => { it("production: x-nonce request header matches the nonce in the CSP response header", async () => { const middleware = await importMiddleware("production"); const req = new NextRequest("http://localhost:3100/settings"); - const res = middleware(req); + const res = await middleware(req); const cspNonce = res.headers.get("Content-Security-Policy")?.match(/'nonce-([^']+)'/)?.[1]; // The nonce is forwarded on the request (for server components) — not readable from // the response directly, but verifiable via the CSP header consistency. @@ -59,9 +66,9 @@ describe("middleware — Content-Security-Policy", () => { it("development: script-src includes unsafe-eval and unsafe-inline for HMR", async () => { const middleware = await importMiddleware("development"); const req = new NextRequest("http://localhost:3100/"); - const res = middleware(req); + const res = await middleware(req); const csp = res.headers.get("Content-Security-Policy") ?? ""; - const scriptSrc = csp.split(";").find((d) => d.trim().startsWith("script-src")) ?? ""; + const scriptSrc = csp.split(";").find((d: string) => d.trim().startsWith("script-src")) ?? ""; expect(scriptSrc).toContain("'unsafe-eval'"); expect(scriptSrc).toContain("'unsafe-inline'"); }); @@ -69,7 +76,7 @@ describe("middleware — Content-Security-Policy", () => { it("frame-ancestors is always 'none' regardless of environment", async () => { for (const env of ["production", "development"] as const) { const middleware = await importMiddleware(env); - const res = middleware(new NextRequest("http://localhost:3100/")); + const res = await middleware(new NextRequest("http://localhost:3100/")); const csp = res.headers.get("Content-Security-Policy") ?? ""; expect(csp).toContain("frame-ancestors 'none'"); } diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index 9633a4d..14c0fba 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -1,4 +1,17 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextResponse } from "next/server"; +import { auth } from "./server/auth-edge.js"; + +// Paths that are accessible without a session. +// Everything else requires a valid JWT session. +const PUBLIC_PREFIXES = [ + "/auth/", // signin, forgot-password, reset-password + "/api/", // tRPC, health, auth endpoints — these manage their own auth + "/invite/", // public invite acceptance flow +]; + +function isPublicPath(pathname: string): boolean { + return PUBLIC_PREFIXES.some((prefix) => pathname.startsWith(prefix)); +} function buildCsp(nonce: string, isProd: boolean): string { const scriptSrc = isProd @@ -20,7 +33,16 @@ function buildCsp(nonce: string, isProd: boolean): string { ].join("; "); } -export function middleware(request: NextRequest): NextResponse { +export default auth(function middleware(request) { + const { pathname } = request.nextUrl; + + // Redirect unauthenticated requests for protected routes to signin + if (!isPublicPath(pathname) && !request.auth) { + const signInUrl = new URL("/auth/signin", request.url); + signInUrl.searchParams.set("callbackUrl", request.url); + return NextResponse.redirect(signInUrl); + } + // Generate a cryptographically random nonce for this request const nonceBytes = new Uint8Array(16); crypto.getRandomValues(nonceBytes); @@ -38,7 +60,7 @@ export function middleware(request: NextRequest): NextResponse { response.headers.set("Content-Security-Policy", csp); return response; -} +}); export const config = { matcher: [ diff --git a/apps/web/src/server/auth-edge.ts b/apps/web/src/server/auth-edge.ts new file mode 100644 index 0000000..ac2943c --- /dev/null +++ b/apps/web/src/server/auth-edge.ts @@ -0,0 +1,6 @@ +import NextAuth from "next-auth"; +import { authConfig } from "./auth.config.js"; + +// Lightweight NextAuth instance for Edge runtime (middleware). +// Does not import any native modules — only verifies JWT sessions. +export const { auth } = NextAuth(authConfig); diff --git a/apps/web/src/server/auth.config.ts b/apps/web/src/server/auth.config.ts new file mode 100644 index 0000000..6d7e6dd --- /dev/null +++ b/apps/web/src/server/auth.config.ts @@ -0,0 +1,45 @@ +import type { NextAuthConfig } from "next-auth"; + +// Edge-safe auth config — no native modules (no argon2, no prisma). +// Used by auth-edge.ts (middleware) to verify JWT sessions without +// pulling in Node.js-only packages into the Edge runtime. +export const authConfig = { + pages: { + signIn: "/auth/signin", + }, + providers: [], + session: { + strategy: "jwt", + maxAge: 28800, // 8 hours absolute timeout + updateAge: 1800, // refresh token every 30 minutes + }, + cookies: { + sessionToken: { + name: "authjs.session-token", + options: { + httpOnly: true, + sameSite: "strict" as const, + path: "/", + secure: process.env.NODE_ENV === "production", + }, + }, + callbackUrl: { + name: "authjs.callback-url", + options: { + httpOnly: true, + sameSite: "strict" as const, + path: "/", + secure: process.env.NODE_ENV === "production", + }, + }, + csrfToken: { + name: "authjs.csrf-token", + options: { + httpOnly: true, + sameSite: "strict" as const, + path: "/", + secure: process.env.NODE_ENV === "production", + }, + }, + }, +} satisfies NextAuthConfig; diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts index b0ae290..7df1b57 100644 --- a/apps/web/src/server/auth.ts +++ b/apps/web/src/server/auth.ts @@ -8,6 +8,7 @@ import { CredentialsSignin } from "next-auth"; import { verify } from "@node-rs/argon2"; import { z } from "zod"; import { assertSecureRuntimeEnv } from "./runtime-env"; +import { authConfig } from "./auth.config.js"; assertSecureRuntimeEnv(); @@ -30,7 +31,8 @@ const LoginSchema = z.object({ totp: z.string().optional(), }); -const authConfig = { +const config = { + ...authConfig, trustHost: true, providers: [ Credentials({ @@ -277,43 +279,6 @@ const authConfig = { }); }, }, - cookies: { - sessionToken: { - name: "authjs.session-token", - options: { - httpOnly: true, - sameSite: "strict" as const, - path: "/", - secure: process.env.NODE_ENV === "production", - }, - }, - callbackUrl: { - name: "authjs.callback-url", - options: { - httpOnly: true, - sameSite: "strict" as const, - path: "/", - secure: process.env.NODE_ENV === "production", - }, - }, - csrfToken: { - name: "authjs.csrf-token", - options: { - httpOnly: true, - sameSite: "strict" as const, - path: "/", - secure: process.env.NODE_ENV === "production", - }, - }, - }, - pages: { - signIn: "/auth/signin", - }, - session: { - strategy: "jwt", - maxAge: 28800, // 8 hours absolute timeout - updateAge: 1800, // Refresh token every 30 minutes (idle timeout) - }, } satisfies NextAuthConfig; -export const { handlers, auth } = NextAuth(authConfig); +export const { handlers, auth } = NextAuth(config);