diff --git a/src/app/api/auth/2fa/route.ts b/src/app/api/auth/2fa/route.ts new file mode 100644 index 0000000..d8641d9 --- /dev/null +++ b/src/app/api/auth/2fa/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { backendFetch } from "../../../../server/backend"; +import { buildSetCookie } from "../../../../server/session"; +import { AuthResponseSchema } from "../../../../api/schemas"; + +export async function POST(req: Request) { + const body = await req.text(); + const upstream = await backendFetch("/v1/auth/2fa/challenge", { method: "POST", body }); + const data = await upstream.json().catch(() => ({})); + if (!upstream.ok) return NextResponse.json(data, { status: upstream.status }); + const parsed = AuthResponseSchema.parse(data); + const res = NextResponse.json({ user: parsed.user }); // token stripped + res.headers.set("Set-Cookie", buildSetCookie(parsed.token)); + return res; +} diff --git a/src/app/api/auth/auth.route.test.ts b/src/app/api/auth/auth.route.test.ts new file mode 100644 index 0000000..03a0726 --- /dev/null +++ b/src/app/api/auth/auth.route.test.ts @@ -0,0 +1,12 @@ +import { describe, it, expect, vi } from "vitest"; +vi.mock("../../../server/backend", () => ({ backendFetch: vi.fn(async () => + new Response(JSON.stringify({ token: "secret", user: { id: 1, email: "a@b.c", createdAt: "2026-01-01T00:00:00Z" } }), { status: 200 })) })); +import { POST } from "./login/route"; +describe("login route", () => { + it("sets cookie and strips token", async () => { + const res = await POST(new Request("http://x/api/auth/login", { method: "POST", body: JSON.stringify({ email: "a@b.c", password: "x" }) })); + const json = await res.clone().json(); + expect(json.token).toBeUndefined(); + expect(res.headers.get("Set-Cookie") ?? "").toMatch(/HttpOnly/i); + }); +}); diff --git a/src/app/api/auth/forgot/route.ts b/src/app/api/auth/forgot/route.ts new file mode 100644 index 0000000..43b91d1 --- /dev/null +++ b/src/app/api/auth/forgot/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; +import { backendFetch } from "../../../../server/backend"; + +export async function POST(req: Request) { + const body = await req.text(); + const upstream = await backendFetch("/v1/auth/forgot", { method: "POST", body }); + const data = await upstream.json().catch(() => ({})); + return NextResponse.json(data, { status: upstream.status }); +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..44ddbaf --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; +import { backendFetch } from "../../../../server/backend"; +import { buildSetCookie } from "../../../../server/session"; +import { AuthResponseSchema } from "../../../../api/schemas"; + +export async function POST(req: Request) { + const body = await req.text(); + const upstream = await backendFetch("/v1/auth/login", { method: "POST", body }); + const data = await upstream.json().catch(() => ({})); + if (!upstream.ok) return NextResponse.json(data, { status: upstream.status }); + // 2FA-enabled accounts return { twoFactor: true, challenge } — pass through, no cookie yet. + if (data?.twoFactor) return NextResponse.json({ twoFactor: true, challenge: data.challenge }); + const parsed = AuthResponseSchema.parse(data); + const res = NextResponse.json({ user: parsed.user }); // token stripped + res.headers.set("Set-Cookie", buildSetCookie(parsed.token)); + return res; +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..e0b5152 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from "next/server"; +import { backendFetch } from "../../../../server/backend"; +import { buildClearCookie, readToken } from "../../../../server/session"; +export async function POST(req: Request) { + const token = readToken(req); + await backendFetch("/v1/auth/logout", { method: "POST" }, token).catch(() => {}); + const res = NextResponse.json({ ok: true }); + res.headers.set("Set-Cookie", buildClearCookie()); + return res; +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..aa2140f --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; +import { backendFetch } from "../../../../server/backend"; +import { buildSetCookie } from "../../../../server/session"; +import { AuthResponseSchema } from "../../../../api/schemas"; + +export async function POST(req: Request) { + const body = await req.text(); + const upstream = await backendFetch("/v1/auth/register", { method: "POST", body }); + const data = await upstream.json().catch(() => ({})); + if (!upstream.ok) return NextResponse.json(data, { status: upstream.status }); + // 2FA-enabled accounts return { twoFactor: true, challenge } — pass through, no cookie yet. + if (data?.twoFactor) return NextResponse.json({ twoFactor: true, challenge: data.challenge }); + const parsed = AuthResponseSchema.parse(data); + const res = NextResponse.json({ user: parsed.user }); // token stripped + res.headers.set("Set-Cookie", buildSetCookie(parsed.token)); + return res; +} diff --git a/src/app/api/auth/reset/route.ts b/src/app/api/auth/reset/route.ts new file mode 100644 index 0000000..f92f9e7 --- /dev/null +++ b/src/app/api/auth/reset/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; +import { backendFetch } from "../../../../server/backend"; + +export async function POST(req: Request) { + const body = await req.text(); + const upstream = await backendFetch("/v1/auth/reset", { method: "POST", body }); + const data = await upstream.json().catch(() => ({})); + return NextResponse.json(data, { status: upstream.status }); +} diff --git a/src/app/api/auth/verify/route.ts b/src/app/api/auth/verify/route.ts new file mode 100644 index 0000000..cdc9d8f --- /dev/null +++ b/src/app/api/auth/verify/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; +import { backendFetch } from "../../../../server/backend"; + +export async function POST(req: Request) { + const body = await req.text(); + const upstream = await backendFetch("/v1/auth/verify", { method: "POST", body }); + const data = await upstream.json().catch(() => ({})); + return NextResponse.json(data, { status: upstream.status }); +} diff --git a/src/app/api/v1/[...path]/route.ts b/src/app/api/v1/[...path]/route.ts new file mode 100644 index 0000000..7955311 --- /dev/null +++ b/src/app/api/v1/[...path]/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from "next/server"; +import { backendFetch } from "../../../../server/backend"; +import { readToken } from "../../../../server/session"; + +async function proxy(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) { + const { path } = await ctx.params; + const token = readToken(req); + const search = req.nextUrl.search; + const body = req.method === "GET" || req.method === "HEAD" ? undefined : await req.text(); + const upstream = await backendFetch(`/v1/${path.join("/")}${search}`, { method: req.method, body }, token); + const text = await upstream.text(); + return new NextResponse(text, { status: upstream.status, headers: { "Content-Type": upstream.headers.get("Content-Type") ?? "application/json" } }); +} +export const GET = proxy; export const POST = proxy; export const PUT = proxy; export const DELETE = proxy; diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..7d32957 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,15 @@ +import { NextRequest, NextResponse } from "next/server"; +import { COOKIE } from "./server/session"; +const PROTECTED = ["/home", "/accounting", "/planner", "/assets", "/profile"]; +export function middleware(req: NextRequest) { + const { pathname } = req.nextUrl; + const hasSession = req.cookies.has(COOKIE); + if (PROTECTED.some((p) => pathname.startsWith(p)) && !hasSession) { + return NextResponse.redirect(new URL("/login", req.url)); + } + if ((pathname === "/login" || pathname === "/") && hasSession) { + return NextResponse.redirect(new URL("/home", req.url)); + } + return NextResponse.next(); +} +export const config = { matcher: ["/", "/login", "/home/:path*", "/accounting/:path*", "/planner/:path*", "/assets/:path*", "/profile/:path*"] }; diff --git a/src/server/backend.ts b/src/server/backend.ts new file mode 100644 index 0000000..3c14ffc --- /dev/null +++ b/src/server/backend.ts @@ -0,0 +1,7 @@ +const BASE = process.env.FMS_API_URL ?? "http://localhost:8080"; +export async function backendFetch(path: string, init: RequestInit = {}, token?: string | null): Promise { + const headers = new Headers(init.headers); + if (token) headers.set("Authorization", `Bearer ${token}`); + if (init.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json"); + return fetch(`${BASE}${path}`, { ...init, headers, cache: "no-store" }); +} diff --git a/src/server/session.test.ts b/src/server/session.test.ts new file mode 100644 index 0000000..89465f0 --- /dev/null +++ b/src/server/session.test.ts @@ -0,0 +1,10 @@ +import { describe, it, expect } from "vitest"; +import { COOKIE, buildSetCookie, buildClearCookie } from "./session"; +describe("session cookie", () => { + it("sets httpOnly Secure SameSite=Lax", () => { + const c = buildSetCookie("tok123"); + expect(c).toContain(`${COOKIE}=tok123`); + expect(c).toMatch(/HttpOnly/i); expect(c).toMatch(/Secure/i); expect(c).toMatch(/SameSite=Lax/i); + }); + it("clear expires the cookie", () => expect(buildClearCookie()).toMatch(/Max-Age=0/i)); +}); diff --git a/src/server/session.ts b/src/server/session.ts new file mode 100644 index 0000000..56ae1bd --- /dev/null +++ b/src/server/session.ts @@ -0,0 +1,12 @@ +export const COOKIE = "mercury_session"; +export function buildSetCookie(token: string): string { + return `${COOKIE}=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${60 * 60 * 24 * 30}`; +} +export function buildClearCookie(): string { + return `${COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`; +} +export function readToken(req: Request): string | null { + const cookie = req.headers.get("cookie") ?? ""; + const m = cookie.match(new RegExp(`(?:^|; )${COOKIE}=([^;]+)`)); + return m ? decodeURIComponent(m[1]) : null; +}