feat(auth): proactive session expiry redirect across all delivery paths
- Split auth config into auth.config.ts (edge-safe, no argon2) and auth-edge.ts for middleware use; auth.ts now spreads the shared config - Middleware wraps with auth() to redirect unauthenticated requests to /auth/signin before any page render; passes through /auth/, /api/, /invite/ paths - SessionGuard client component watches useSession() and redirects on status=unauthenticated, closing the SPA navigation gap - QueryCache + MutationCache in TRPCProvider redirect on UNAUTHORIZED tRPC errors without retrying; SessionProvider polls session state every 5 minutes - Middleware tests updated for async auth wrapper and auth-edge mock Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AppShell } from "~/components/layout/AppShell.js";
|
import { AppShell } from "~/components/layout/AppShell.js";
|
||||||
import { MfaPromptBanner } from "~/components/security/MfaPromptBanner.js";
|
import { MfaPromptBanner } from "~/components/security/MfaPromptBanner.js";
|
||||||
|
import { SessionGuard } from "~/components/security/SessionGuard.js";
|
||||||
import { auth } from "~/server/auth.js";
|
import { auth } from "~/server/auth.js";
|
||||||
|
|
||||||
const MFA_PROMPT_ROLES = new Set(["ADMIN", "MANAGER"]);
|
const MFA_PROMPT_ROLES = new Set(["ADMIN", "MANAGER"]);
|
||||||
@@ -17,6 +18,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell userRole={userRole}>
|
<AppShell userRole={userRole}>
|
||||||
|
<SessionGuard />
|
||||||
{MFA_PROMPT_ROLES.has(userRole) && <MfaPromptBanner />}
|
{MFA_PROMPT_ROLES.has(userRole) && <MfaPromptBanner />}
|
||||||
{children}
|
{children}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"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 { httpBatchLink, loggerLink } from "@trpc/client";
|
||||||
import { SessionProvider } from "next-auth/react";
|
import { SessionProvider } from "next-auth/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -12,6 +13,17 @@ function getBaseUrl() {
|
|||||||
return `http://localhost:${process.env["PORT"] ?? 3100}`;
|
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 {
|
function isIgnorableTransportError(error: unknown): boolean {
|
||||||
const message =
|
const message =
|
||||||
typeof error === "object" && error !== null && "message" in error
|
typeof error === "object" && error !== null && "message" in error
|
||||||
@@ -25,12 +37,26 @@ export function TRPCProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [queryClient] = useState(
|
const [queryClient] = useState(
|
||||||
() =>
|
() =>
|
||||||
new QueryClient({
|
new QueryClient({
|
||||||
|
queryCache: new QueryCache({
|
||||||
|
onError: (error) => {
|
||||||
|
if (isUnauthorizedTrpcError(error)) redirectToSignIn();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mutationCache: new MutationCache({
|
||||||
|
onError: (error) => {
|
||||||
|
if (isUnauthorizedTrpcError(error)) redirectToSignIn();
|
||||||
|
},
|
||||||
|
}),
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 60_000, // 60 seconds — reduces refetches on navigation
|
staleTime: 60_000, // 60 seconds — reduces refetches on navigation
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: 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 (
|
return (
|
||||||
<SessionProvider>
|
<SessionProvider refetchInterval={5 * 60}>
|
||||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
</trpc.Provider>
|
</trpc.Provider>
|
||||||
|
|||||||
@@ -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";
|
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) {
|
async function importMiddleware(nodeEnv: string) {
|
||||||
vi.stubEnv("NODE_ENV", nodeEnv);
|
vi.stubEnv("NODE_ENV", nodeEnv);
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
const mod = await import("./middleware.js");
|
const mod = await import("./middleware.js");
|
||||||
return mod.middleware;
|
// middleware is the default export (wrapped by auth())
|
||||||
|
return mod.default as (req: NextRequest) => Promise<Response>;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("middleware — Content-Security-Policy", () => {
|
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 () => {
|
it("sets a Content-Security-Policy header on every response", async () => {
|
||||||
const middleware = await importMiddleware("production");
|
const middleware = await importMiddleware("production");
|
||||||
const req = new NextRequest("http://localhost:3100/");
|
const req = new NextRequest("http://localhost:3100/");
|
||||||
const res = middleware(req);
|
const res = await middleware(req);
|
||||||
expect(res.headers.get("Content-Security-Policy")).toBeTruthy();
|
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 () => {
|
it("production: script-src contains a nonce and does NOT contain unsafe-inline or unsafe-eval", async () => {
|
||||||
const middleware = await importMiddleware("production");
|
const middleware = await importMiddleware("production");
|
||||||
const req = new NextRequest("http://localhost:3100/dashboard");
|
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 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).toMatch(/'nonce-[A-Za-z0-9+/=]+'/);
|
||||||
expect(scriptSrc).not.toContain("'unsafe-inline'");
|
expect(scriptSrc).not.toContain("'unsafe-inline'");
|
||||||
expect(scriptSrc).not.toContain("'unsafe-eval'");
|
expect(scriptSrc).not.toContain("'unsafe-eval'");
|
||||||
@@ -36,8 +43,8 @@ describe("middleware — Content-Security-Policy", () => {
|
|||||||
|
|
||||||
it("production: each request gets a unique nonce", async () => {
|
it("production: each request gets a unique nonce", async () => {
|
||||||
const middleware = await importMiddleware("production");
|
const middleware = await importMiddleware("production");
|
||||||
const res1 = middleware(new NextRequest("http://localhost:3100/a"));
|
const res1 = await middleware(new NextRequest("http://localhost:3100/a"));
|
||||||
const res2 = middleware(new NextRequest("http://localhost:3100/b"));
|
const res2 = await middleware(new NextRequest("http://localhost:3100/b"));
|
||||||
const nonce1 = res1.headers.get("Content-Security-Policy")?.match(/'nonce-([^']+)'/)?.[1];
|
const nonce1 = res1.headers.get("Content-Security-Policy")?.match(/'nonce-([^']+)'/)?.[1];
|
||||||
const nonce2 = res2.headers.get("Content-Security-Policy")?.match(/'nonce-([^']+)'/)?.[1];
|
const nonce2 = res2.headers.get("Content-Security-Policy")?.match(/'nonce-([^']+)'/)?.[1];
|
||||||
expect(nonce1).toBeTruthy();
|
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 () => {
|
it("production: x-nonce request header matches the nonce in the CSP response header", async () => {
|
||||||
const middleware = await importMiddleware("production");
|
const middleware = await importMiddleware("production");
|
||||||
const req = new NextRequest("http://localhost:3100/settings");
|
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];
|
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 nonce is forwarded on the request (for server components) — not readable from
|
||||||
// the response directly, but verifiable via the CSP header consistency.
|
// 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 () => {
|
it("development: script-src includes unsafe-eval and unsafe-inline for HMR", async () => {
|
||||||
const middleware = await importMiddleware("development");
|
const middleware = await importMiddleware("development");
|
||||||
const req = new NextRequest("http://localhost:3100/");
|
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 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-eval'");
|
||||||
expect(scriptSrc).toContain("'unsafe-inline'");
|
expect(scriptSrc).toContain("'unsafe-inline'");
|
||||||
});
|
});
|
||||||
@@ -69,7 +76,7 @@ describe("middleware — Content-Security-Policy", () => {
|
|||||||
it("frame-ancestors is always 'none' regardless of environment", async () => {
|
it("frame-ancestors is always 'none' regardless of environment", async () => {
|
||||||
for (const env of ["production", "development"] as const) {
|
for (const env of ["production", "development"] as const) {
|
||||||
const middleware = await importMiddleware(env);
|
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") ?? "";
|
const csp = res.headers.get("Content-Security-Policy") ?? "";
|
||||||
expect(csp).toContain("frame-ancestors 'none'");
|
expect(csp).toContain("frame-ancestors 'none'");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
function buildCsp(nonce: string, isProd: boolean): string {
|
||||||
const scriptSrc = isProd
|
const scriptSrc = isProd
|
||||||
@@ -20,7 +33,16 @@ function buildCsp(nonce: string, isProd: boolean): string {
|
|||||||
].join("; ");
|
].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
|
// Generate a cryptographically random nonce for this request
|
||||||
const nonceBytes = new Uint8Array(16);
|
const nonceBytes = new Uint8Array(16);
|
||||||
crypto.getRandomValues(nonceBytes);
|
crypto.getRandomValues(nonceBytes);
|
||||||
@@ -38,7 +60,7 @@ export function middleware(request: NextRequest): NextResponse {
|
|||||||
response.headers.set("Content-Security-Policy", csp);
|
response.headers.set("Content-Security-Policy", csp);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
});
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -8,6 +8,7 @@ import { CredentialsSignin } from "next-auth";
|
|||||||
import { verify } from "@node-rs/argon2";
|
import { verify } from "@node-rs/argon2";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { assertSecureRuntimeEnv } from "./runtime-env";
|
import { assertSecureRuntimeEnv } from "./runtime-env";
|
||||||
|
import { authConfig } from "./auth.config.js";
|
||||||
|
|
||||||
assertSecureRuntimeEnv();
|
assertSecureRuntimeEnv();
|
||||||
|
|
||||||
@@ -30,7 +31,8 @@ const LoginSchema = z.object({
|
|||||||
totp: z.string().optional(),
|
totp: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const authConfig = {
|
const config = {
|
||||||
|
...authConfig,
|
||||||
trustHost: true,
|
trustHost: true,
|
||||||
providers: [
|
providers: [
|
||||||
Credentials({
|
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;
|
} satisfies NextAuthConfig;
|
||||||
|
|
||||||
export const { handlers, auth } = NextAuth(authConfig);
|
export const { handlers, auth } = NextAuth(config);
|
||||||
|
|||||||
Reference in New Issue
Block a user