From 1aeabb2c372e24329730695926df36a275ac2853 Mon Sep 17 00:00:00 2001 From: Hqzdev Date: Tue, 30 Jun 2026 20:41:02 +0500 Subject: [PATCH 1/2] security: move web auth to httpOnly cookies --- .../app/api/web/auth/email/verify/route.ts | 10 ++ apps/web/app/api/web/auth/login/route.ts | 6 + apps/web/app/api/web/auth/logout/route.ts | 5 + apps/web/app/api/web/auth/me/route.ts | 8 + apps/web/app/api/web/auth/register/route.ts | 10 ++ apps/web/app/api/web/documents/route.ts | 17 ++ apps/web/app/api/web/questions/route.ts | 10 ++ apps/web/components/AuthPage.tsx | 4 +- apps/web/components/Workspace.tsx | 70 +++------ apps/web/lib/api.ts | 135 ++++------------ apps/web/lib/auth-cookies.ts | 2 + apps/web/lib/auth-session.ts | 49 ------ apps/web/lib/http.ts | 60 ++++++++ apps/web/lib/server-auth.ts | 49 ++++++ apps/web/lib/server-gateway.ts | 145 ++++++++++++++++++ apps/web/proxy.ts | 17 ++ docs/auth-security.md | 21 +++ 17 files changed, 405 insertions(+), 213 deletions(-) create mode 100644 apps/web/app/api/web/auth/email/verify/route.ts create mode 100644 apps/web/app/api/web/auth/login/route.ts create mode 100644 apps/web/app/api/web/auth/logout/route.ts create mode 100644 apps/web/app/api/web/auth/me/route.ts create mode 100644 apps/web/app/api/web/auth/register/route.ts create mode 100644 apps/web/app/api/web/documents/route.ts create mode 100644 apps/web/app/api/web/questions/route.ts create mode 100644 apps/web/lib/auth-cookies.ts delete mode 100644 apps/web/lib/auth-session.ts create mode 100644 apps/web/lib/http.ts create mode 100644 apps/web/lib/server-auth.ts create mode 100644 apps/web/lib/server-gateway.ts create mode 100644 apps/web/proxy.ts diff --git a/apps/web/app/api/web/auth/email/verify/route.ts b/apps/web/app/api/web/auth/email/verify/route.ts new file mode 100644 index 0000000..e4e76aa --- /dev/null +++ b/apps/web/app/api/web/auth/email/verify/route.ts @@ -0,0 +1,10 @@ +import { NextRequest } from "next/server"; +import { proxyPublic } from "@/lib/server-gateway"; + +export async function POST(request: NextRequest) { + return proxyPublic({ + method: "POST", + path: "/api/auth/email/verify", + body: await request.json() + }); +} diff --git a/apps/web/app/api/web/auth/login/route.ts b/apps/web/app/api/web/auth/login/route.ts new file mode 100644 index 0000000..77d3726 --- /dev/null +++ b/apps/web/app/api/web/auth/login/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { loginThroughGateway } from "@/lib/server-gateway"; + +export async function POST(request: NextRequest) { + return loginThroughGateway(await request.json()); +} diff --git a/apps/web/app/api/web/auth/logout/route.ts b/apps/web/app/api/web/auth/logout/route.ts new file mode 100644 index 0000000..cdbf659 --- /dev/null +++ b/apps/web/app/api/web/auth/logout/route.ts @@ -0,0 +1,5 @@ +import { logoutThroughGateway } from "@/lib/server-gateway"; + +export async function POST() { + return logoutThroughGateway(); +} diff --git a/apps/web/app/api/web/auth/me/route.ts b/apps/web/app/api/web/auth/me/route.ts new file mode 100644 index 0000000..da0a641 --- /dev/null +++ b/apps/web/app/api/web/auth/me/route.ts @@ -0,0 +1,8 @@ +import { proxyPrivate } from "@/lib/server-gateway"; + +export async function GET() { + return proxyPrivate({ + method: "GET", + path: "/api/auth/me" + }); +} diff --git a/apps/web/app/api/web/auth/register/route.ts b/apps/web/app/api/web/auth/register/route.ts new file mode 100644 index 0000000..e5ac93f --- /dev/null +++ b/apps/web/app/api/web/auth/register/route.ts @@ -0,0 +1,10 @@ +import { NextRequest } from "next/server"; +import { proxyPublic } from "@/lib/server-gateway"; + +export async function POST(request: NextRequest) { + return proxyPublic({ + method: "POST", + path: "/api/auth/register", + body: await request.json() + }); +} diff --git a/apps/web/app/api/web/documents/route.ts b/apps/web/app/api/web/documents/route.ts new file mode 100644 index 0000000..543f534 --- /dev/null +++ b/apps/web/app/api/web/documents/route.ts @@ -0,0 +1,17 @@ +import { NextRequest } from "next/server"; +import { proxyPrivate } from "@/lib/server-gateway"; + +export async function GET() { + return proxyPrivate({ + method: "GET", + path: "/api/documents" + }); +} + +export async function POST(request: NextRequest) { + return proxyPrivate({ + method: "POST", + path: "/api/documents", + body: await request.json() + }); +} diff --git a/apps/web/app/api/web/questions/route.ts b/apps/web/app/api/web/questions/route.ts new file mode 100644 index 0000000..989cafb --- /dev/null +++ b/apps/web/app/api/web/questions/route.ts @@ -0,0 +1,10 @@ +import { NextRequest } from "next/server"; +import { proxyPrivate } from "@/lib/server-gateway"; + +export async function POST(request: NextRequest) { + return proxyPrivate({ + method: "POST", + path: "/api/questions", + body: await request.json() + }); +} diff --git a/apps/web/components/AuthPage.tsx b/apps/web/components/AuthPage.tsx index 892caf2..5e45648 100644 --- a/apps/web/components/AuthPage.tsx +++ b/apps/web/components/AuthPage.tsx @@ -5,7 +5,6 @@ import { useRouter } from "next/navigation"; import { FormEvent, useState } from "react"; import { Logo } from "@/components/Chrome"; import { ApiError, login, register, verifyEmail } from "@/lib/api"; -import { storeSession } from "@/lib/auth-session"; type AuthPageProps = { mode: "login" | "register"; @@ -53,13 +52,12 @@ export function AuthPage({ mode }: AuthPageProps) { await verifyEmail(registration.dev_verification_token); } - const session = await login({ + await login({ email, password, device_label: navigator.userAgent }); - storeSession(session); router.push("/app"); } catch (error) { setStatus({ diff --git a/apps/web/components/Workspace.tsx b/apps/web/components/Workspace.tsx index 38f9f2f..5b828b4 100644 --- a/apps/web/components/Workspace.tsx +++ b/apps/web/components/Workspace.tsx @@ -5,8 +5,7 @@ import { useRouter } from "next/navigation"; import { FormEvent, useEffect, useMemo, useState } from "react"; import { AnimatedNumber } from "@/components/AnimatedNumber"; import { Logo } from "@/components/Chrome"; -import { ApiError, AnswerResponse, DocumentSummary, askQuestion, getMe, listDocuments, logout, queueDocument, refresh } from "@/lib/api"; -import { StoredAuthSession, clearStoredSession, readStoredSession, storeSession } from "@/lib/auth-session"; +import { ApiError, AnswerResponse, DocumentSummary, TenantSummary, UserSummary, askQuestion, getMe, listDocuments, logout, queueDocument } from "@/lib/api"; const navItems = ["Chat", "Documents", "Usage", "Settings"]; @@ -17,7 +16,8 @@ type WorkspaceStatus = { export function Workspace() { const router = useRouter(); - const [session, setSession] = useState(null); + const [user, setUser] = useState(null); + const [tenant, setTenant] = useState(null); const [documents, setDocuments] = useState([]); const [answer, setAnswer] = useState(null); const [status, setStatus] = useState(null); @@ -25,7 +25,6 @@ export function Workspace() { const [isUploading, setIsUploading] = useState(false); const [isAsking, setIsAsking] = useState(false); - const tenant = session?.tenant; const usageMetrics = useMemo( () => [ { label: "Indexed docs", value: documents.filter((document) => document.status === "indexed").length }, @@ -40,51 +39,25 @@ export function Workspace() { let isActive = true; async function loadWorkspace() { - const storedSession = readStoredSession(); - - if (!storedSession) { - router.replace("/login"); - return; - } - try { - const me = await getMe(storedSession.accessToken); - const nextSession = { - ...storedSession, - user: me.user, - tenant: me.current_tenant - }; - const nextDocuments = await listDocuments(nextSession.accessToken); + const me = await getMe(); + const nextDocuments = await listDocuments(); if (!isActive) { return; } - localStorage.setItem("grounded.auth", JSON.stringify(nextSession)); - setSession(nextSession); + setUser(me.user); + setTenant(me.current_tenant); setDocuments(nextDocuments); } catch (error) { - if (!(error instanceof ApiError) || error.status !== 401) { - if (isActive) { - setStatus({ tone: "error", message: resolveWorkspaceError(error) }); - setSession(storedSession); - } + if (error instanceof ApiError && error.status === 401) { + router.replace("/login"); return; } - try { - const refreshedSession = storeSession(await refresh(storedSession.refreshToken)); - const nextDocuments = await listDocuments(refreshedSession.accessToken); - - if (!isActive) { - return; - } - - setSession(refreshedSession); - setDocuments(nextDocuments); - } catch { - clearStoredSession(); - router.replace("/login"); + if (isActive) { + setStatus({ tone: "error", message: resolveWorkspaceError(error) }); } } finally { if (isActive) { @@ -103,7 +76,7 @@ export function Workspace() { async function handleUpload(event: FormEvent) { event.preventDefault(); - if (!session) { + if (!user) { router.replace("/login"); return; } @@ -121,10 +94,9 @@ export function Workspace() { title, content_type: "text/markdown", content - }, - session.accessToken + } ); - setDocuments(await listDocuments(session.accessToken)); + setDocuments(await listDocuments()); setStatus({ tone: "success", message: "Document queued for indexing." }); event.currentTarget.reset(); } catch (error) { @@ -137,7 +109,7 @@ export function Workspace() { async function handleAsk(event: FormEvent) { event.preventDefault(); - if (!session) { + if (!user) { router.replace("/login"); return; } @@ -148,7 +120,7 @@ export function Workspace() { setIsAsking(true); try { - setAnswer(await askQuestion({ question }, session.accessToken)); + setAnswer(await askQuestion({ question })); event.currentTarget.reset(); } catch (error) { setStatus({ tone: "error", message: resolveWorkspaceError(error) }); @@ -158,13 +130,7 @@ export function Workspace() { } async function handleLogout() { - if (session) { - try { - await logout(session.accessToken); - } catch {} - } - - clearStoredSession(); + await logout(); router.replace("/login"); } @@ -191,7 +157,7 @@ export function Workspace() {
{tenant?.name ?? "Workspace"} -

{session?.user.email}

+

{user?.email}

diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index b2f3bad..69b020a 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -1,4 +1,6 @@ -const gatewayUrl = process.env.NEXT_PUBLIC_GATEWAY_URL ?? "http://localhost:8080"; +import { ApiError, parseResponse, resolveErrorMessage } from "@/lib/http"; + +export { ApiError }; export type TenantSummary = { id: string; @@ -14,9 +16,7 @@ export type UserSummary = { email_verified: boolean; }; -export type AuthResponse = { - access_token: string; - refresh_token: string; +export type AuthSessionResponse = { token_type: string; expires_in: number; user: UserSummary; @@ -91,100 +91,66 @@ export type AnswerResponse = { created_at: string; }; -export class ApiError extends Error { - status: number; - - constructor(status: number, message: string) { - super(message); - this.status = status; - } -} - -export async function getGatewayHealth() { - return request("/api/health", { method: "GET" }); -} - export async function register(payload: RegisterRequest) { - return request("/api/auth/register", { + return request("/api/web/auth/register", { method: "POST", body: payload }); } export async function login(payload: LoginRequest) { - return request("/api/auth/login", { + return request("/api/web/auth/login", { method: "POST", body: payload }); } export async function verifyEmail(token: string) { - return request("/api/auth/email/verify", { + return request("/api/web/auth/email/verify", { method: "POST", body: { token } }); } -export async function refresh(refreshToken: string) { - return request("/api/auth/refresh", { - method: "POST", - body: { refresh_token: refreshToken } - }); -} - -export async function logout(accessToken: string) { - return request("/api/auth/logout", { - method: "POST", - accessToken +export async function logout() { + return request("/api/web/auth/logout", { + method: "POST" }); } -export async function getMe(accessToken: string) { - return request("/api/auth/me", { - method: "GET", - accessToken +export async function getMe() { + return request("/api/web/auth/me", { + method: "GET" }); } -export async function listDocuments(accessToken: string) { - return request("/api/documents", { - method: "GET", - accessToken +export async function listDocuments() { + return request("/api/web/documents", { + method: "GET" }); } -export async function askQuestion(payload: QuestionRequest, accessToken?: string) { - return request("/api/questions", { +export async function askQuestion(payload: QuestionRequest) { + return request("/api/web/questions", { method: "POST", - body: payload, - accessToken + body: payload }); } -export async function queueDocument(payload: DocumentRequest, accessToken?: string) { - return request("/api/documents", { +export async function queueDocument(payload: DocumentRequest) { + return request("/api/web/documents", { method: "POST", - body: payload, - accessToken + body: payload }); } async function request(path: string, options: RequestOptions): Promise { - const headers = new Headers(); - - if (options.body !== undefined) { - headers.set("Content-Type", "application/json"); - } - - if (options.accessToken) { - headers.set("Authorization", `Bearer ${options.accessToken}`); - } - - const response = await fetch(`${gatewayUrl}${path}`, { + const response = await fetch(path, { method: options.method, - headers, + headers: options.body === undefined ? undefined : { "Content-Type": "application/json" }, body: options.body === undefined ? undefined : JSON.stringify(options.body), - cache: "no-store" + cache: "no-store", + credentials: "same-origin" }); const payload = await parseResponse(response); @@ -196,56 +162,7 @@ async function request(path: string, options: RequestOptions): Promise { return payload as T; } -async function parseResponse(response: Response) { - const text = await response.text(); - - if (!text) { - return {}; - } - - try { - return JSON.parse(text); - } catch { - return { message: text }; - } -} - -function resolveErrorMessage(payload: unknown, status: number) { - if (isErrorPayload(payload)) { - if (typeof payload.detail === "string") { - return payload.detail; - } - - if (Array.isArray(payload.detail)) { - return payload.detail - .map((item) => resolveValidationMessage(item)) - .filter(Boolean) - .join(" "); - } - - if (typeof payload.message === "string") { - return payload.message; - } - } - - return `Request failed with status ${status}`; -} - -function isErrorPayload(payload: unknown): payload is { detail?: unknown; message?: unknown } { - return typeof payload === "object" && payload !== null; -} - -function resolveValidationMessage(item: unknown) { - if (typeof item !== "object" || item === null || !("msg" in item)) { - return ""; - } - - const message = (item as { msg?: unknown }).msg; - return typeof message === "string" ? message : ""; -} - type RequestOptions = { method: "GET" | "POST" | "DELETE"; body?: unknown; - accessToken?: string; }; diff --git a/apps/web/lib/auth-cookies.ts b/apps/web/lib/auth-cookies.ts new file mode 100644 index 0000000..ee272d4 --- /dev/null +++ b/apps/web/lib/auth-cookies.ts @@ -0,0 +1,2 @@ +export const accessCookieName = "grounded_access_token"; +export const refreshCookieName = "grounded_refresh_token"; diff --git a/apps/web/lib/auth-session.ts b/apps/web/lib/auth-session.ts deleted file mode 100644 index 027e62a..0000000 --- a/apps/web/lib/auth-session.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { AuthResponse, TenantSummary, UserSummary } from "@/lib/api"; - -export type StoredAuthSession = { - accessToken: string; - refreshToken: string; - expiresIn: number; - user: UserSummary; - tenant: TenantSummary | null; -}; - -const authStorageKey = "grounded.auth"; - -export function readStoredSession() { - if (typeof window === "undefined") { - return null; - } - - const value = localStorage.getItem(authStorageKey); - - if (!value) { - return null; - } - - try { - return JSON.parse(value) as StoredAuthSession; - } catch { - clearStoredSession(); - return null; - } -} - -export function storeSession(session: AuthResponse) { - const value: StoredAuthSession = { - accessToken: session.access_token, - refreshToken: session.refresh_token, - expiresIn: session.expires_in, - user: session.user, - tenant: session.tenant - }; - - localStorage.setItem(authStorageKey, JSON.stringify(value)); - return value; -} - -export function clearStoredSession() { - if (typeof window !== "undefined") { - localStorage.removeItem(authStorageKey); - } -} diff --git a/apps/web/lib/http.ts b/apps/web/lib/http.ts new file mode 100644 index 0000000..075e61b --- /dev/null +++ b/apps/web/lib/http.ts @@ -0,0 +1,60 @@ +export class ApiError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +export async function parseResponse(response: Response) { + const text = await response.text(); + + if (!text) { + return {}; + } + + try { + return JSON.parse(text); + } catch { + return { message: text }; + } +} + +export function resolveErrorMessage(payload: unknown, status: number) { + if (isErrorPayload(payload)) { + if (typeof payload.detail === "string") { + return payload.detail; + } + + if (Array.isArray(payload.detail)) { + const message = payload.detail + .map((item) => resolveValidationMessage(item)) + .filter(Boolean) + .join(" "); + + if (message) { + return message; + } + } + + if (typeof payload.message === "string") { + return payload.message; + } + } + + return `Request failed with status ${status}`; +} + +function isErrorPayload(payload: unknown): payload is { detail?: unknown; message?: unknown } { + return typeof payload === "object" && payload !== null; +} + +function resolveValidationMessage(item: unknown) { + if (typeof item !== "object" || item === null || !("msg" in item)) { + return ""; + } + + const message = (item as { msg?: unknown }).msg; + return typeof message === "string" ? message : ""; +} diff --git a/apps/web/lib/server-auth.ts b/apps/web/lib/server-auth.ts new file mode 100644 index 0000000..275d727 --- /dev/null +++ b/apps/web/lib/server-auth.ts @@ -0,0 +1,49 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; +import { accessCookieName, refreshCookieName } from "@/lib/auth-cookies"; + +const accessTokenMaxAge = 15 * 60; +const refreshTokenMaxAge = 14 * 24 * 60 * 60; + +export type GatewayAuthResponse = { + access_token: string; + refresh_token: string; +}; + +export async function getAccessToken() { + return (await cookies()).get(accessCookieName)?.value ?? null; +} + +export async function getRefreshToken() { + return (await cookies()).get(refreshCookieName)?.value ?? null; +} + +export function setAuthCookies(response: NextResponse, session: GatewayAuthResponse) { + response.cookies.set(accessCookieName, session.access_token, cookieOptions(accessTokenMaxAge)); + response.cookies.set(refreshCookieName, session.refresh_token, cookieOptions(refreshTokenMaxAge)); +} + +export function clearAuthCookies(response: NextResponse) { + response.cookies.set(accessCookieName, "", expiredCookieOptions()); + response.cookies.set(refreshCookieName, "", expiredCookieOptions()); +} + +function cookieOptions(maxAge: number) { + return { + httpOnly: true, + sameSite: "lax" as const, + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge + }; +} + +function expiredCookieOptions() { + return { + httpOnly: true, + sameSite: "lax" as const, + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 0 + }; +} diff --git a/apps/web/lib/server-gateway.ts b/apps/web/lib/server-gateway.ts new file mode 100644 index 0000000..0f76f0c --- /dev/null +++ b/apps/web/lib/server-gateway.ts @@ -0,0 +1,145 @@ +import { NextResponse } from "next/server"; +import { clearAuthCookies, getAccessToken, getRefreshToken, setAuthCookies } from "@/lib/server-auth"; +import { parseResponse, resolveErrorMessage } from "@/lib/http"; + +const gatewayUrl = process.env.GROUNDED_GATEWAY_URL ?? process.env.NEXT_PUBLIC_GATEWAY_URL ?? "http://localhost:8080"; + +type ProxyOptions = { + method: "GET" | "POST" | "DELETE"; + path: string; + body?: unknown; +}; + +export async function proxyPublic(options: ProxyOptions) { + const response = await callGateway(options); + const payload = await parseResponse(response); + return NextResponse.json(payload, { status: response.status }); +} + +export async function proxyPrivate(options: ProxyOptions) { + let accessToken = await getAccessToken(); + let refreshedSession = null; + + if (!accessToken) { + refreshedSession = await refreshSession(); + + if (!refreshedSession) { + return NextResponse.json({ message: "Authentication required." }, { status: 401 }); + } + + accessToken = refreshedSession.access_token; + } + + let response = await callGateway(options, accessToken); + + if (response.status === 401) { + refreshedSession = await refreshSession(); + + if (!refreshedSession) { + const unauthorized = NextResponse.json({ message: "Authentication expired." }, { status: 401 }); + clearAuthCookies(unauthorized); + return unauthorized; + } + + response = await callGateway(options, refreshedSession.access_token); + } + + const payload = await parseResponse(response); + const nextResponse = NextResponse.json(payload, { status: response.status }); + + if (refreshedSession) { + setAuthCookies(nextResponse, refreshedSession); + } + + return nextResponse; +} + +export async function loginThroughGateway(body: unknown) { + const response = await callGateway({ method: "POST", path: "/api/auth/login", body }); + const payload = await parseResponse(response); + const nextResponse = NextResponse.json(sanitizeAuthPayload(payload), { status: response.status }); + + if (response.ok && isAuthPayload(payload)) { + setAuthCookies(nextResponse, payload); + } + + return nextResponse; +} + +export async function logoutThroughGateway() { + const accessToken = await getAccessToken(); + + if (accessToken) { + await callGateway({ method: "POST", path: "/api/auth/logout" }, accessToken); + } + + const response = NextResponse.json({ message: "Logged out." }); + clearAuthCookies(response); + return response; +} + +async function refreshSession() { + const refreshToken = await getRefreshToken(); + + if (!refreshToken) { + return null; + } + + const response = await callGateway({ + method: "POST", + path: "/api/auth/refresh", + body: { refresh_token: refreshToken } + }); + const payload = await parseResponse(response); + + if (!response.ok || !isAuthPayload(payload)) { + return null; + } + + return payload; +} + +async function callGateway(options: ProxyOptions, accessToken?: string) { + const headers = new Headers(); + + if (options.body !== undefined) { + headers.set("Content-Type", "application/json"); + } + + if (accessToken) { + headers.set("Authorization", `Bearer ${accessToken}`); + } + + return fetch(`${gatewayUrl}${options.path}`, { + method: options.method, + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + cache: "no-store" + }); +} + +function sanitizeAuthPayload(payload: unknown) { + if (!isAuthPayload(payload)) { + return payload; + } + + const { access_token, refresh_token, ...safePayload } = payload; + return safePayload; +} + +function isAuthPayload(payload: unknown): payload is { + access_token: string; + refresh_token: string; + [key: string]: unknown; +} { + return typeof payload === "object" + && payload !== null + && "access_token" in payload + && "refresh_token" in payload + && typeof (payload as { access_token?: unknown }).access_token === "string" + && typeof (payload as { refresh_token?: unknown }).refresh_token === "string"; +} + +export function gatewayError(status: number, payload: unknown) { + return NextResponse.json({ message: resolveErrorMessage(payload, status) }, { status }); +} diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts new file mode 100644 index 0000000..8faed2f --- /dev/null +++ b/apps/web/proxy.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from "next/server"; +import { accessCookieName, refreshCookieName } from "@/lib/auth-cookies"; + +export function proxy(request: NextRequest) { + const hasAccessToken = request.cookies.has(accessCookieName); + const hasRefreshToken = request.cookies.has(refreshCookieName); + + if (!hasAccessToken && !hasRefreshToken) { + return NextResponse.redirect(new URL("/login", request.url)); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/app/:path*"] +}; diff --git a/docs/auth-security.md b/docs/auth-security.md index 6321de5..12fd81e 100644 --- a/docs/auth-security.md +++ b/docs/auth-security.md @@ -63,6 +63,10 @@ Access tokens are JWTs with `sub`, `tid`, `sid`, `jti`, issuer, audience, issued Refresh tokens are opaque random values. The database stores only a keyed hash. Refresh tokens rotate on every refresh. A reused, expired, revoked, or already-used refresh token invalidates the whole session family. +The web application shall not expose access tokens or refresh tokens to browser JavaScript. The Next.js web boundary stores tokens in `httpOnly` cookies and proxies browser requests through server-side BFF routes. + +Authentication cookies are necessary cookies. They must use `SameSite=Lax`, path `/`, `httpOnly`, and `Secure` in production. + ## 5. Email Verification Registration creates an unverified user, tenant, owner membership, email verification token, and audit event. Login is denied until email verification completes. @@ -87,6 +91,21 @@ The system shall write audit events for registration, verification, login succes ## 9. API Access Rules +Browser-facing web routes: + +```text +POST /api/web/auth/register +POST /api/web/auth/login +POST /api/web/auth/logout +POST /api/web/auth/email/verify +GET /api/web/auth/me +GET /api/web/documents +POST /api/web/documents +POST /api/web/questions +``` + +These routes shall proxy the gateway from the server side. Browser JavaScript shall not attach bearer tokens manually. + Public routes: ```text @@ -129,6 +148,8 @@ POST /api/questions | AAC-005 | Password reset revokes active sessions. | | AAC-006 | Private API routes reject missing bearer tokens. | | AAC-007 | Backend smoke test passes through register, verify, login, upload, index, question, and citation. | +| AAC-008 | Browser JavaScript cannot read access tokens or refresh tokens. | +| AAC-009 | Web authentication uses `httpOnly` cookies and server-side proxy routes. | ## 11. References From aed08cb4d7cf9a1c6e06c9feed9bc77c18027373 Mon Sep 17 00:00:00 2001 From: Hqzdev Date: Tue, 30 Jun 2026 20:41:41 +0500 Subject: [PATCH 2/2] feat: add cookie consent and legal pages --- README.md | 2 + apps/web/app/cookies/page.tsx | 33 ++++++++ apps/web/app/globals.css | 106 +++++++++++++++++++++++++ apps/web/app/layout.tsx | 2 + apps/web/app/privacy/page.tsx | 33 ++++++++ apps/web/app/security/page.tsx | 33 ++++++++ apps/web/app/terms/page.tsx | 33 ++++++++ apps/web/components/Chrome.tsx | 4 + apps/web/components/CookieConsent.tsx | 110 ++++++++++++++++++++++++++ apps/web/components/LegalPage.tsx | 33 ++++++++ docs/README.md | 12 +-- docs/privacy-and-cookies.md | 86 ++++++++++++++++++++ 12 files changed, 482 insertions(+), 5 deletions(-) create mode 100644 apps/web/app/cookies/page.tsx create mode 100644 apps/web/app/privacy/page.tsx create mode 100644 apps/web/app/security/page.tsx create mode 100644 apps/web/app/terms/page.tsx create mode 100644 apps/web/components/CookieConsent.tsx create mode 100644 apps/web/components/LegalPage.tsx create mode 100644 docs/privacy-and-cookies.md diff --git a/README.md b/README.md index 869f96b..ace9a80 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ The backend now uses an auth-first request model. Users register into a tenant, Protected document and question APIs require `Authorization: Bearer $ACCESS_TOKEN`. Auth state is backed by Prisma-managed PostgreSQL tables for user sessions, refresh tokens, verification tokens, password reset tokens, and audit events. +The web application uses server-side Next.js routes for browser authentication. Access and refresh tokens are stored in `httpOnly` cookies, not in browser JavaScript. Public privacy, terms, cookie, and security pages are available from the footer and consent menu. + Implemented auth flows: | Flow | Endpoint | diff --git a/apps/web/app/cookies/page.tsx b/apps/web/app/cookies/page.tsx new file mode 100644 index 0000000..def0381 --- /dev/null +++ b/apps/web/app/cookies/page.tsx @@ -0,0 +1,33 @@ +import { LegalPage } from "@/components/LegalPage"; + +export default function CookiesPage() { + return ( + + ); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 362aa6d..ee1cfe8 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -704,6 +704,112 @@ button { gap: 20px; } +.cookie-consent { + background: rgba(251, 250, 246, 0.96); + border: 1px solid var(--line); + border-radius: 16px; + bottom: 22px; + box-shadow: var(--shadow); + display: grid; + gap: 18px; + left: 22px; + max-width: min(680px, calc(100vw - 44px)); + padding: 22px; + position: fixed; + z-index: 80; +} + +.cookie-consent h2 { + font-size: 24px; + letter-spacing: -0.02em; + margin: 0 0 8px; +} + +.cookie-consent p { + color: var(--muted); + font-size: 14px; + line-height: 1.5; + margin: 0; +} + +.cookie-links, +.cookie-actions, +.cookie-options { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.cookie-links { + font-size: 13px; + font-weight: 700; + margin-top: 12px; +} + +.cookie-links a { + color: #0b7d52; +} + +.cookie-options { + background: #eff6ec; + border: 1px solid var(--line-strong); + border-radius: 12px; + padding: 12px; +} + +.cookie-options label { + align-items: center; + display: inline-flex; + font-size: 13px; + font-weight: 700; + gap: 8px; +} + +.legal-page { + padding: clamp(54px, 8vw, 92px) 0; +} + +.legal-page h1 { + font-size: clamp(42px, 6vw, 72px); + letter-spacing: -0.045em; + line-height: 0.98; + margin: 0 0 18px; +} + +.legal-intro { + color: var(--muted); + font-size: 18px; + line-height: 1.55; + margin: 0; + max-width: 760px; +} + +.legal-sections { + display: grid; + gap: 14px; + margin-top: 34px; + max-width: 860px; +} + +.legal-section { + background: var(--surface); + border: 1px solid var(--line); + border-radius: 14px; + padding: 22px; +} + +.legal-section h2 { + font-size: 21px; + letter-spacing: -0.01em; + margin: 0 0 8px; +} + +.legal-section p { + color: var(--muted); + line-height: 1.6; + margin: 0; +} + .auth-page { align-items: stretch; display: grid; diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 932b249..4690313 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import type { ReactNode } from "react"; +import { CookieConsent } from "@/components/CookieConsent"; import { CustomCursor } from "@/components/CustomCursor"; import "./globals.css"; @@ -14,6 +15,7 @@ export default function RootLayout({ children }: { children: ReactNode }) { {children} + ); diff --git a/apps/web/app/privacy/page.tsx b/apps/web/app/privacy/page.tsx new file mode 100644 index 0000000..0516e62 --- /dev/null +++ b/apps/web/app/privacy/page.tsx @@ -0,0 +1,33 @@ +import { LegalPage } from "@/components/LegalPage"; + +export default function PrivacyPage() { + return ( + + ); +} diff --git a/apps/web/app/security/page.tsx b/apps/web/app/security/page.tsx new file mode 100644 index 0000000..5154c1e --- /dev/null +++ b/apps/web/app/security/page.tsx @@ -0,0 +1,33 @@ +import { LegalPage } from "@/components/LegalPage"; + +export default function SecurityPage() { + return ( + + ); +} diff --git a/apps/web/app/terms/page.tsx b/apps/web/app/terms/page.tsx new file mode 100644 index 0000000..b38010e --- /dev/null +++ b/apps/web/app/terms/page.tsx @@ -0,0 +1,33 @@ +import { LegalPage } from "@/components/LegalPage"; + +export default function TermsPage() { + return ( + + ); +} diff --git a/apps/web/components/Chrome.tsx b/apps/web/components/Chrome.tsx index 4ccd6af..1fe823c 100644 --- a/apps/web/components/Chrome.tsx +++ b/apps/web/components/Chrome.tsx @@ -55,6 +55,10 @@ export function Footer() { {label} ))} + Privacy + Terms + Cookies + Security Log in 2026 ยท self-hostable diff --git a/apps/web/components/CookieConsent.tsx b/apps/web/components/CookieConsent.tsx new file mode 100644 index 0000000..3fa04ea --- /dev/null +++ b/apps/web/components/CookieConsent.tsx @@ -0,0 +1,110 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; + +type ConsentPreferences = { + necessary: true; + analytics: boolean; + preferences: boolean; + marketing: boolean; +}; + +const cookieName = "grounded_cookie_consent"; +const defaultPreferences: ConsentPreferences = { + necessary: true, + analytics: false, + preferences: false, + marketing: false +}; + +export function CookieConsent() { + const [isVisible, setIsVisible] = useState(false); + const [isCustomizing, setIsCustomizing] = useState(false); + const [preferences, setPreferences] = useState(defaultPreferences); + + useEffect(() => { + const timer = window.setTimeout(() => { + setIsVisible(!readConsentCookie()); + }, 0); + + return () => window.clearTimeout(timer); + }, []); + + if (!isVisible) { + return null; + } + + function save(nextPreferences: ConsentPreferences) { + const value = encodeURIComponent(JSON.stringify(nextPreferences)); + document.cookie = `${cookieName}=${value}; Max-Age=${180 * 24 * 60 * 60}; Path=/; SameSite=Lax`; + setIsVisible(false); + } + + function updatePreference(key: keyof Omit) { + setPreferences((current) => ({ + ...current, + [key]: !current[key] + })); + } + + return ( +
+
+

Cookie Preferences

+

Choose how Grounded uses cookies.

+

+ Necessary cookies keep authentication, session security, and consent choices working. Optional cookies help with analytics, + interface preferences, and future marketing measurement. +

+
+ Cookie Policy + Privacy Policy + Terms +
+
+ {isCustomizing ? ( +
+ + + + +
+ ) : null} +
+ + + + {isCustomizing ? ( + + ) : null} +
+
+ ); +} + +function readConsentCookie() { + return document.cookie + .split("; ") + .some((cookie) => cookie.startsWith(`${cookieName}=`)); +} diff --git a/apps/web/components/LegalPage.tsx b/apps/web/components/LegalPage.tsx new file mode 100644 index 0000000..9c86657 --- /dev/null +++ b/apps/web/components/LegalPage.tsx @@ -0,0 +1,33 @@ +import { Footer, Header } from "@/components/Chrome"; + +type LegalPageProps = { + eyebrow: string; + title: string; + intro: string; + sections: Array<{ + title: string; + body: string; + }>; +}; + +export function LegalPage({ eyebrow, title, intro, sections }: LegalPageProps) { + return ( +
+
+
+

{eyebrow}

+

{title}

+

{intro}

+
+ {sections.map((section) => ( +
+

{section.title}

+

{section.body}

+
+ ))} +
+
+
+ ); +} diff --git a/docs/README.md b/docs/README.md index 3b6f2ec..9619cd1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,6 +45,7 @@ This register defines the controlled documentation set for Grounded. The documen | `database.md` | Database requirements and data model specification. | Draft | | `api-contracts.md` | Public API requirements and response contracts. | Draft | | `auth-security.md` | Authentication, session, token, password, and audit requirements. | Draft | +| `privacy-and-cookies.md` | Privacy pages, cookie categories, consent, and necessary auth cookies. | Draft | | `ci-cd.md` | Continuous integration checks, branch policy, and delivery roadmap. | Draft | | `ingestion-pipeline.md` | Document ingestion and asynchronous processing requirements. | Draft | | `retrieval-pipeline.md` | Retrieval, answer generation, citation, and usage requirements. | Draft | @@ -60,11 +61,12 @@ This register defines the controlled documentation set for Grounded. The documen 4. Read Database before implementing persistence. 5. Read API Contracts before connecting the frontend. 6. Read Auth Security before changing authentication or private API access. -7. Read CI/CD before changing GitHub Actions, release, or branch policy. -8. Read Ingestion Pipeline and Retrieval Pipeline before implementing RAG flows. -9. Read Tenant Isolation before writing tenant-scoped queries. -10. Read Local Development before running the system. -11. Read Development Workflow before opening a pull request. +7. Read Privacy and Cookies before changing consent, legal pages, or browser storage. +8. Read CI/CD before changing GitHub Actions, release, or branch policy. +9. Read Ingestion Pipeline and Retrieval Pipeline before implementing RAG flows. +10. Read Tenant Isolation before writing tenant-scoped queries. +11. Read Local Development before running the system. +12. Read Development Workflow before opening a pull request. ## 4. Documentation Standards diff --git a/docs/privacy-and-cookies.md b/docs/privacy-and-cookies.md new file mode 100644 index 0000000..b4c432f --- /dev/null +++ b/docs/privacy-and-cookies.md @@ -0,0 +1,86 @@ +# Privacy And Cookie Requirements + +| Field | Value | +| --- | --- | +| Project | Grounded | +| Document Type | Privacy and Cookie Requirements | +| Version | 0.1 | +| Revision Date | 2026-06-30 | +| Status | Draft | +| Owner | Engineering | + +## Revision History + +| Version | Date | Author | Description | +| --- | --- | --- | --- | +| 0.1 | 2026-06-30 | Engineering | Initial privacy pages and cookie consent requirements. | + +## Review and Approval + +| Role | Name | Status | Date | +| --- | --- | --- | --- | +| Product Owner | TBD | Pending | TBD | +| Security Reviewer | TBD | Pending | TBD | +| Legal Reviewer | TBD | Pending | TBD | + +## Contents + +1. Purpose +2. Public Legal Pages +3. Cookie Categories +4. Consent Requirements +5. Authentication Cookies +6. Acceptance Criteria + +## 1. Purpose + +This document defines the privacy page, terms page, cookie policy page, security overview page, and cookie consent behavior for the Grounded web application. + +## 2. Public Legal Pages + +The web application shall expose the following public pages: + +| Page | Purpose | +| --- | --- | +| `/privacy` | Describe account, tenant, document, question, citation, and operational data processing. | +| `/terms` | Describe acceptable use, user responsibility, and reliance limits. | +| `/cookies` | Describe necessary and optional cookie categories. | +| `/security` | Summarize authentication, token handling, tenant isolation, and audit posture. | + +## 3. Cookie Categories + +| Category | Required | Purpose | +| --- | --- | --- | +| Necessary | Yes | Authentication, session security, consent storage, and application integrity. | +| Analytics | No | Product usage, reliability, and performance analysis. | +| Preferences | No | Non-sensitive interface preferences. | +| Marketing | No | Campaign attribution and product communication measurement. | + +## 4. Consent Requirements + +The consent menu shall offer: + +| Choice | Behavior | +| --- | --- | +| Use necessary only | Store only required cookies. | +| Allow all | Enable all optional categories. | +| Customize | Let the user choose optional categories individually. | + +Necessary cookies cannot be disabled because product login and security depend on them. + +## 5. Authentication Cookies + +Authentication cookies shall be treated as necessary cookies. They shall be `httpOnly`, `SameSite=Lax`, path-scoped to `/`, and marked `Secure` in production. + +Browser JavaScript shall not read access tokens or refresh tokens. + +## 6. Acceptance Criteria + +| ID | Criterion | +| --- | --- | +| PAC-001 | The consent menu appears before a consent preference is stored. | +| PAC-002 | The user can choose necessary-only cookies. | +| PAC-003 | The user can allow all cookie categories. | +| PAC-004 | The user can customize optional categories. | +| PAC-005 | Legal links are visible from the consent menu and footer. | +| PAC-006 | Authentication remains functional when optional categories are disabled. |